Nachrichten und Blogs im Buch-Reader, Doppelklick für Vollbild, Hörbücher wie die Bücher-Bibliothek
Some checks failed
Build and push Docker image / build (push) Successful in 18s
Test / test (push) Successful in 11m2s
Test / e2e (push) Failing after 1m36s

65-article.js (DioraArticle): Artikel öffnen im selben Reader wie Bücher – Schrift, Farben,
Seiten/Doppelseite mit Fußnoten am Seitenfuß, Suche, Vorlesen, Vollbild. Lesezeichen,
Markierungen und Notizspalte bleiben Büchern vorbehalten. Merken, Projekt und Original
stehen in der Kopfzeile (Apps: im Lesemenü). feeds.js gibt sein eigenes Overlay auf;
Fortschritt wie bisher (bis zum unteren Bildschirmrand gelesen, ab 90 % gelesen).
Einstellungen der Artikel gelten für alle Artikel zusammen.

62-reader.js: Doppelklick (Maus) auf den Text schaltet Vollbild an/aus, die Leisten gehen mit.

audiobooks.js: die Hörbücher sind gebaut wie die Bücher – Kopf mit Suche, Sortierung und +,
Chips Angefangen/Ungehört/Gehört und Ordner, „Weiterhören“ wie „Weiterlesen“, Kacheln aus
denselben Bausteinen (53-library.js gibt seine Styles auch an #audiobook-shelf), Cover quadratisch.

63-pages.js: bei nur einem Kapitel zeigt die Fußzeile nur die Seiten.

sw CACHE v158

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
mrwnslz 2026-10-26 20:15:00 +01:00
parent 6bb774adb9
commit e8b525a3de
11 changed files with 521 additions and 229 deletions

View file

@ -1901,6 +1901,17 @@ html.nui-tts-on body.reader-immersive .reader-overlay { bottom: var(--bar-h); }
#reader-overlay.reader-spread #reader-spread-page { display:block; overflow:hidden; box-shadow:inset 1px 0 0 var(--border);
user-select:none; -webkit-user-select:none; cursor:default; }
#reader-spread-page::after { content:""; display:block; height:100vh; }
/* An article in the reader (65-article.js): what only books have stays out. */
#reader-overlay.reader-article-mode .reader-progress-wrap,
#reader-overlay.reader-article-mode #reader-bookmark-btn,
#reader-overlay.reader-article-mode #reader-bm-list-btn,
#reader-overlay.reader-article-mode #reader-toc-btn,
#reader-overlay.reader-article-mode #reader-margin-btn,
#reader-overlay.reader-article-mode #reader-reset-pos-btn,
#reader-overlay.reader-article-mode .marker-mode-btn,
#reader-overlay.reader-article-mode #reader-margin,
#reader-overlay.reader-article-mode .nui-toc { display:none !important; }
.reader-article-actions { display:inline-flex; gap:10px; align-items:center; }
/* The browser's line under the page (the apps have their own footer). */
#reader-page-footer { display:flex; justify-content:space-between; gap:12px; flex-shrink:0; padding:4px 16px 6px;
font-size:12px; color:var(--muted,#888); font-variant-numeric:tabular-nums; }

View file

@ -933,7 +933,7 @@ function _libAddMenu() {
(function () {
const style = document.createElement('style');
style.textContent = [
const rules = [
'html.lib-v2 #tab-books .book-drop-zone, html.lib-v2 #tab-books .book-list-filter,',
'html.lib-v2 #book-upload-area > button.btn, html.lib-v2 #book-upload-area > .nui-add-row { display: none !important; }',
'#book-list .lib-head { display: flex; align-items: center; gap: 6px; margin: 4px 0 8px; }',
@ -1026,6 +1026,15 @@ function _libAddMenu() {
'.cloud-chosen:empty { display: none; }',
'.cloud-entry .cloud-check { width: 18px; height: 18px; margin: 0 8px 0 2px; flex: none; }',
'body.eink #book-list .lib-cover { box-shadow: none; border: 1px solid #000; }',
].join('\n');
];
// The audiobooks' shelf (audiobooks.js) is built of the same parts: the same rules for it,
// its covers square like an audiobook's.
const shelf = rules.filter(r => r.indexOf('#book-list') >= 0).map(r => r.replace(/#book-list/g, '#audiobook-shelf'));
style.textContent = rules.concat(shelf, [
'#audiobook-shelf .lib-cover { aspect-ratio: 1; }',
'#audiobook-shelf .lib-hero-cover { height: auto; aspect-ratio: 1; width: 120px; align-self: flex-start; }',
'@media (min-width: 721px) { #audiobook-shelf .lib-hero-cover { width: 160px; height: auto; } }',
'#audiobook-shelf button.lib-play { border: none; font: inherit; font-size: 14px; font-weight: 600; cursor: pointer; }',
]).join('\n');
document.head.appendChild(style);
})();

View file

@ -29,6 +29,49 @@ function _immHandleTap(e) {
}
}
// A double click on the text (with a mouse): full screen, the bars out of the
// way; again (or Esc): back as it was. Fingers keep their double tap; beside
// the text a click turns the page (63-pages.js), so only on the text itself.
let _readerFsHidBars = false;
function _readerDblClick(e) {
if (!matchMedia('(hover: hover) and (pointer: fine)').matches) return;
if (!e.target.closest('[data-epub-src], .pdf-page-wrapper')) return;
if (e.target.closest('button, a, input, select, textarea, label, [data-footnote-ref], mark.epub-highlight')) return;
// The double click also picked a word: that's not a marking.
const sel = window.getSelection();
if (sel) sel.removeAllRanges();
if (typeof dismissHighlightPopover === 'function') dismissHighlightPopover();
if (document.fullscreenElement) {
if (document.exitFullscreen) document.exitFullscreen().catch(() => {});
return;
}
const el = document.documentElement;
if (!el.requestFullscreen) return;
el.requestFullscreen().then(() => {
if (_immBarsVisible) {
_immBarsVisible = false;
document.body.classList.add('reader-immersive');
_readerFsHidBars = true;
}
}).catch(() => {});
}
document.addEventListener('fullscreenchange', () => {
if (document.fullscreenElement || !_readerFsHidBars) return;
_readerFsHidBars = false;
const overlay = $('reader-overlay');
if (overlay && overlay.style.display !== 'none' && !_immBarsVisible) {
_immBarsVisible = true;
document.body.classList.remove('reader-immersive');
}
});
document.addEventListener('DOMContentLoaded', () => {
const row = $('reader-body-row');
if (row) row.addEventListener('dblclick', _readerDblClick);
});
function enterReaderImmersiveMode() {
_immBarsVisible = true;
document.body.classList.remove('reader-immersive');
@ -549,6 +592,8 @@ function closeReader() {
// Still opening: just leave it (nothing to save yet).
if (cancelBookOpening()) return;
if (typeof _issueReaderClosed === 'function') _issueReaderClosed();
// An article (65-article.js): where it was read to, before the reader empties.
if (typeof _articleReaderClosed === 'function') _articleReaderClosed();
exitReaderImmersiveMode();
stopReadAloud();
// Save progress BEFORE hiding — scrollHeight/clientHeight return 0 once display:none

View file

@ -763,7 +763,9 @@
const idx = Array.prototype.indexOf.call(chapters, cur);
const f = globalFraction(c);
return {
left: 'Kap. ' + (idx + 1) + '/' + chapters.length + ' · S. ' + current + (two ? '–' + (current + 1) : '') + '/' + total,
// One chapter (an article, a single-file book): just its pages.
left: (chapters.length > 1 ? 'Kap. ' + (idx + 1) + '/' + chapters.length + ' · ' : '') +
'S. ' + current + (two ? '–' + (current + 1) : '') + '/' + total,
right: f === null ? '' : _formatBookPercent(f) + ' %',
};
}
@ -830,6 +832,18 @@
update();
}
// A book or an article (65-article.js) is in the reader now.
function opened() {
const c = content();
if (!c) return;
c.classList.toggle('nui-window', on && !isPdf());
history = [];
fractionCache = null;
paneDirty = true;
ensureChapter(c); // a new book has no saved position to reveal
if (paging()) relayout(250);
}
function setSpread(value) {
spreadWanted = !!value;
set(KEY_SPREAD, spreadWanted ? '1' : '0');
@ -876,10 +890,7 @@
fractionCache = null;
paneDirty = true;
const r = await orig.apply(this, arguments);
history = [];
if (c && isPdf()) c.classList.remove('nui-window');
ensureChapter(content()); // a new book has no saved position to reveal
if (paging()) relayout(250);
opened();
return r;
});
wrap('restoreFromAnchor', orig => function (contentEl, anchor) {
@ -982,6 +993,7 @@
jumpToFraction,
footerText,
relayout,
opened,
onTurn: fn => turnHooks.push(fn),
};

View file

@ -15,7 +15,11 @@ function _readerTypeDefaults() {
? _READER_TYPE_DEFAULTS_WIDE : _READER_TYPE_DEFAULTS_NARROW;
}
// Where the settings shown are kept: the book's id, or 'article' (all articles, 65-article.js).
let _readerSettingsKey = null;
function loadReaderSettings(bookId) {
_readerSettingsKey = bookId;
// Reset to defaults, then apply per-book overrides
Object.assign(readerSettings, { theme: 'app', fontFamily: 'serif', noBold: false,
pdfZoom: 100, pdfInverted: false, pdfPaginated: false, pdfSpread: false },
@ -40,8 +44,9 @@ function loadReaderSettings(bookId) {
}
function saveReaderSettings() {
if (currentBookId) {
localStorage.setItem(`diora_reader_settings_${currentBookId}`, JSON.stringify({ ...readerSettings, v: 3 }));
const key = currentBookId || _readerSettingsKey;
if (key) {
localStorage.setItem(`diora_reader_settings_${key}`, JSON.stringify({ ...readerSettings, v: 3 }));
}
}

173
static/js/app/65-article.js Normal file
View file

@ -0,0 +1,173 @@
'use strict';
// ---------------------------------------------------------------------------
// Articles in the book reader (DioraArticle) — blog posts and news (feeds.js)
// read like books: the same overlay, typography and colours (⚙, kept for all
// articles together), pages or scrolling (63-pages.js) with the notes at the
// page's foot, search, read-aloud, full screen. Bookmarks, markings and the
// notes margin stay with books (an article has nowhere to keep them).
//
// The article is one "chapter" in #reader-content; currentBookId stays null,
// so nothing of a book's position or annotations is saved. Where it was read
// to goes back to its owner (onProgress), as do clicks on its own buttons.
// ---------------------------------------------------------------------------
(function () {
let session = null; // {title, html, startFraction, onProgress, onClick, onClose, actions}
let progressTimer = 0;
const overlay = () => document.getElementById('reader-overlay');
const content = () => document.getElementById('reader-content');
// Same-page links: the notes of a blog post ("¹" → "#fn1") become references
// like in books; other jumps inside the post must not change the app's
// address (#books, #feeds …): kept as internal links (the apps follow them).
function markLinks(root) {
root.querySelectorAll('a[href^="#"]').forEach(a => {
const href = a.getAttribute('href');
a.removeAttribute('href');
if (href.length < 2) return;
if (!_isNoteBacklink(a) && _looksLikeFootnoteLink(a, href)) {
a.dataset.footnoteRef = href.slice(1);
a.dataset.footnoteHref = href;
a.style.cursor = 'pointer';
} else {
a.dataset.internalHref = href;
}
});
// Everything else opens outside.
root.querySelectorAll('a[href]').forEach(a => {
a.target = '_blank';
a.rel = 'noopener noreferrer';
});
}
// How far it is read: down to the bottom of what is on screen (0-1).
function fraction() {
const c = content();
const ch = c && c.querySelector(':scope > [data-epub-src]');
if (!ch || !ch.offsetHeight) return 0;
return Math.max(0, Math.min(1, (c.scrollTop + c.clientHeight - ch.offsetTop) / ch.offsetHeight));
}
function report() {
if (session && session.onProgress) session.onProgress(fraction());
}
function onScroll() {
clearTimeout(progressTimer);
progressTimer = setTimeout(report, 1200);
}
function onClick(e) {
if (session && session.onClick) session.onClick(e);
}
// The article's own buttons (star, project, original): in the browser's
// header; the apps put them into their reader menu (native-ui.js asks actions()).
function renderActions() {
const bar = document.querySelector('#reader-overlay .reader-header-actions');
if (!bar) return;
let box = bar.querySelector('.reader-article-actions');
if (!box) {
box = document.createElement('span');
box.className = 'reader-article-actions';
bar.insertBefore(box, bar.firstChild);
}
box.textContent = '';
actions().forEach(a => {
const b = document.createElement('button');
b.type = 'button';
b.className = 'btn-icon' + (a.active ? ' active' : '');
b.title = a.label;
b.textContent = a.icon;
b.addEventListener('click', e => { e.stopPropagation(); a.action(); });
box.appendChild(b);
});
}
function actions() {
return session && session.actions ? session.actions().filter(Boolean) : [];
}
async function open(o) {
const ov = overlay();
const c = content();
if (!ov || !c) return;
if (session) {
report();
stopReadAloud(); // the next article: the voice was reading the last one
}
else if (ov.style.display !== 'none') closeReader(); // a book was open
session = o;
loadReaderSettings('article');
_charIndex = null;
_readPos = null;
currentBookId = null;
ov.classList.add('reader-article-mode');
$('reader-title').textContent = o.title || '';
c.textContent = '';
const ch = document.createElement('div');
ch.setAttribute('data-epub-src', 'article');
ch.id = 'epub-chapter-0';
ch.className = 'fd-artview';
ch.innerHTML = o.html;
markLinks(ch);
c.appendChild(ch);
ov.style.display = '';
applyReaderSettings(false);
enterReaderImmersiveMode();
renderActions();
c.scrollTop = 0;
if (window.DioraPages) DioraPages.opened();
if (window.DioraUI && DioraUI.updateFooter) DioraUI.updateFooter();
const f = o.startFraction || 0;
if (f > 0.02 && f < 0.9) {
// Back where reading stopped (on any device).
await new Promise(r => requestAnimationFrame(r));
if (session !== o) return;
_suppressScrollJumpDetect();
c.scrollTop = Math.max(0, f * ch.offsetHeight + ch.offsetTop - c.clientHeight);
} else {
setTimeout(() => { if (session === o) report(); }, 1500); // short enough to fit: read at once
}
}
// Replaces what is shown (the overview of a story arrived) without moving.
function update(html) {
const ch = content() && content().querySelector(':scope > [data-epub-src="article"]');
if (!ch || !session) return;
session.html = html;
ch.innerHTML = html;
markLinks(ch);
}
// closeReader (62-reader.js) calls this before it empties the reader.
window._articleReaderClosed = function () {
if (!session) return;
clearTimeout(progressTimer);
report();
const s = session;
session = null;
overlay().classList.remove('reader-article-mode');
const box = document.querySelector('#reader-overlay .reader-article-actions');
if (box) box.remove();
if (s.onClose) setTimeout(s.onClose, 0);
};
window.DioraArticle = {
open,
update,
isOpen: () => !!session,
close: () => { if (session) closeReader(); },
actions,
refreshActions: renderActions,
fraction,
};
document.addEventListener('DOMContentLoaded', () => {
const c = content();
if (!c) return;
c.addEventListener('scroll', () => { if (session) onScroll(); }, {passive: true});
c.addEventListener('click', e => { if (session) onClick(e); });
});
})();

View file

@ -219,7 +219,8 @@ function handleReaderSelection(e) {
return;
}
if (!markerModeActive) return;
// Markings belong to books (an article has nowhere to keep them, 65-article.js).
if (!markerModeActive || !currentBookId) return;
// Don't hijack taps on real interactive content (footnote refs, external
// links) — but plenty of EPUBs wrap plain reading text in a bare, hrefless
// <a id="..."> used only as an internal jump target (sanitizeEpubHtml

View file

@ -31,6 +31,10 @@
folder: null, // the folder chip chosen (null: all, '': without a folder)
folderOrder: [], // the podcasts' folder order (/podcasts/folders/)
series: null, // the series shown (its key), inside the folder
q: '', // the search in the shelf's head
searching: false, // the search field open (narrow screens show a ⌕ first)
sort: cached('sort') || 'recent',
status: 'all', // the state chip: all, started, unheard, heard
};
const shelf = document.createElement('div');
@ -175,45 +179,6 @@
return st.detail;
}
// ---- the shelf in "Bücher" ------------------------------------------------------------
function renderShelf() {
const h = host();
const books = st.books || [];
const state = h ? podcastState() : null;
const pods = state && state.feeds ? state.feeds.filter(f => f.kind !== 'audiobook') : [];
if (!h) { shelf.innerHTML = ''; return; }
if (!books.length && !pods.length) {
shelf.innerHTML = '<p class="ab-empty">Hörbücher und Hörspiele sind hier Podcasts, die wie ein Buch gehört ' +
'werden: Kapitel in Reihenfolge, Lesezeichen. Erst einen Podcast abonnieren (Podcasts → Suche), dann hier markieren.</p>';
return;
}
let html = '';
const go = books.filter(b => b.next && b.last_played_at)[0];
if (go) {
html += '<div class="ab-continue" data-ab-open="' + go.feed_id + '">' +
cover(go.artwork_url, go.title, 'ab-cover ab-cover-s') +
'<div class="ab-card-text"><div class="ab-label">Weiterhören</div>' +
'<div class="ab-title">' + esc(go.title) + '</div>' +
'<div class="ab-meta">' + esc(progressText(go)) + '</div>' +
'<div class="ab-bar"><span style="width:' + pct(go) + '%"></span></div></div>' +
'<button type="button" class="ab-play" data-ab-go="' + go.feed_id + '" aria-label="Weiterhören">▶</button></div>';
}
html += '<div class="ab-head"><h3 class="ab-h">Hörbücher</h3>' +
(pods.length ? '<button type="button" class="ab-link" data-ab="pick">+ Hörbuch</button>' : '') + '</div>';
if (books.length) {
html += gridHtml(books);
} else {
html += '<p class="ab-empty">Podcasts, die eigentlich Hörbücher oder Hörspiele sind, lassen sich hier ' +
'wie ein Buch hören: in Kapitelreihenfolge, mit Lesezeichen. „+ Hörbuch“ wählt einen aus.</p>';
}
if (st.picking) html += pickHtml(pods);
shelf.innerHTML = html;
drawChips(books);
const input = shelf.querySelector('.ab-pick-q');
if (input && document.activeElement !== input && st.pickFocus) { input.focus(); st.pickFocus = false; }
}
// ---- organising: folders (set per book) and series (found in the titles) ----------------
// "Das Kapital – Erster Band", "Harry Potter: Teil 3", "Die drei ??? – Folge 12":
@ -270,28 +235,131 @@
x.sort.localeCompare(y.sort, 'de'));
}
// ---- the shelf, built like the books' library (53-library.js: its lib-* parts, and its
// styles, which hold for #audiobook-shelf too): a head with search, sort and +, chips for
// the state and the folders, "Weiterhören" on top, the books as cards. ---------------------
const SORTS = [['recent', 'Zuletzt'], ['title', 'Titel'], ['author', 'Autor'],
['long', 'Längste zuerst'], ['short', 'Kürzeste zuerst']];
const heard = b => !b.next;
const started = b => !!b.next && (b.heard_seconds > 0 || b.done > 0 || !!b.last_played_at);
const unheard = b => !!b.next && !started(b);
const STATES = {started, unheard, heard};
function renderShelf() {
const h = host();
const books = st.books || [];
const state = h ? podcastState() : null;
const pods = state && state.feeds ? state.feeds.filter(f => f.kind !== 'audiobook') : [];
if (!h) { shelf.innerHTML = ''; return; }
const narrow = window.matchMedia && matchMedia('(max-width: 720px)').matches;
let html = '<div class="lib-head"><h2 class="lib-h">Hörbücher</h2>' +
(!narrow || st.searching || st.q
? '<input class="lib-q" type="search" placeholder="Titel, Autor …" value="' + esc(st.q) + '">'
: '<button type="button" class="lib-icon" data-ab="search" aria-label="Suchen">⌕</button>') +
'<select class="lib-sort" aria-label="Sortieren">' + SORTS.map(s =>
'<option value="' + s[0] + '"' + (st.sort === s[0] ? ' selected' : '') + '>' + s[1] + '</option>').join('') + '</select>' +
(pods.length ? '<button type="button" class="lib-icon lib-add" data-ab="pick" aria-label="Hörbuch hinzufügen" ' +
'title="Einen abonnierten Podcast als Hörbuch führen">+</button>' : '') +
'</div><div class="lib-chips"></div>';
if (st.picking) html += pickHtml(pods);
if (!books.length) {
html += '<p class="lib-empty">' + (pods.length
? 'Podcasts, die eigentlich Hörbücher oder Hörspiele sind, lassen sich hier wie ein Buch hören: ' +
'in Kapitelreihenfolge, mit Lesezeichen. Mit + einen auswählen.'
: 'Hörbücher und Hörspiele sind hier Podcasts, die wie ein Buch gehört werden: Kapitel in Reihenfolge, ' +
'Lesezeichen. Erst einen Podcast abonnieren (Podcasts → Suche), dann hier mit + markieren.') + '</p>';
} else {
html += listHtml(books);
}
const q = shelf.querySelector('.lib-q');
const focused = q && document.activeElement === q;
shelf.innerHTML = html;
drawChips(books);
if (focused || (st.searching && !st.q)) {
const n = shelf.querySelector('.lib-q');
if (n) { n.focus(); n.setSelectionRange(n.value.length, n.value.length); }
}
const input = shelf.querySelector('.ab-pick-q');
if (input && document.activeElement !== input && st.pickFocus) { input.focus(); st.pickFocus = false; }
}
function shownBooks(books) {
const q = (st.q || '').trim().toLowerCase();
return books.filter(b =>
(st.folder == null ? true : st.folder === '' ? !foldersOf(b).length : foldersOf(b).indexOf(st.folder) >= 0) &&
(!STATES[st.status] || STATES[st.status](b)) &&
(!q || (b.title + ' ' + (b.author || '')).toLowerCase().indexOf(q) >= 0));
}
// The list's order (entries() comes "Zuletzt": the last heard first).
function sorted(list) {
const books = e => (e.kind === 'series' ? e.books : [e.b]);
const length = e => books(e).reduce((n, b) => n + (b.total_seconds || 0), 0);
const author = e => books(e)[0].author || '';
const by = {
title: (x, y) => x.sort.localeCompare(y.sort, 'de'),
author: (x, y) => author(x).localeCompare(author(y), 'de') || x.sort.localeCompare(y.sort, 'de'),
long: (x, y) => length(y) - length(x),
short: (x, y) => length(x) - length(y),
}[st.sort];
return by ? list.slice().sort(by) : list;
}
function listHtml(books) {
const here = shownBooks(books);
// Series as stacks, unless searching or filtering by state (as with the books).
const stack = !st.q && !STATES[st.status];
const list = sorted(stack ? entries(here) : here.map(b => ({kind: 'book', b, sort: b.title})));
if (st.series != null) {
const g = list.filter(e => e.kind === 'series' && e.key === st.series)[0];
if (g) {
return '<div class="lib-place"><button type="button" class="lib-btn" data-ab="up">‹ Hörbücher</button>' +
'<strong class="lib-place-name">' + esc(g.name) + '</strong></div>' +
'<div class="lib-grid">' + g.books.map(b => bookCard(b, b._volume)).join('') + '</div>';
}
st.series = null;
}
let html = '';
const go = !st.q && st.folder == null && !STATES[st.status] && books.filter(b => b.next && b.last_played_at)[0];
if (go) html += continueHtml(go);
if (!list.length) return html + '<p class="lib-empty">Nichts gefunden.</p>';
return html + '<div class="lib-grid">' + list.map(e => e.kind === 'series' ? seriesCard(e) : bookCard(e.b)).join('') + '</div>';
}
// "Weiterhören": the book last listened to, large — like "Weiterlesen".
function continueHtml(b) {
return '<section class="lib-recent" aria-label="Weiterhören"><div class="lib-continue" data-ab-open="' + b.feed_id + '">' +
cover(b.artwork_url, b.title, 'lib-hero-cover', b.author) +
'<span class="lib-text"><span class="lib-kicker">Weiterhören</span>' +
'<span class="lib-title lib-hero-title">' + esc(b.title) + '</span>' +
(b.author ? '<span class="lib-meta">' + esc(b.author) + '</span>' : '') +
'<span class="lib-hero-progress"><span class="lib-bar"><span style="width:' + pct(b) + '%"></span></span>' +
'<span class="lib-meta">' + esc(progressText(b)) + '</span></span>' +
'<button type="button" class="lib-play" data-ab-go="' + b.feed_id + '">▶ Weiterhören</button></span></div></section>';
}
function bookCard(b, label) {
return '<button type="button" class="ab-card" data-ab-open="' + b.feed_id + '">' +
cover(b.artwork_url, b.title, 'ab-cover') +
'<span class="ab-card-text"><span class="ab-title">' + esc(label || b.title) + '</span>' +
(b.author && !label ? '<span class="ab-meta">' + esc(b.author) + '</span>' : '') +
'<span class="ab-meta">' + esc(progressText(b)) + '</span>' +
'<span class="ab-bar"><span style="width:' + pct(b) + '%"></span></span></span></button>';
return '<div class="lib-card" data-ab-open="' + b.feed_id + '">' +
'<span class="lib-coverbox">' + cover(b.artwork_url, b.title, 'lib-cover', b.author) +
(heard(b) ? '<span class="lib-badge">✓</span>' : '') + '</span>' +
'<span class="lib-bar"><span style="width:' + pct(b) + '%"></span></span>' +
'<span class="lib-title">' + esc(label || b.title) + '</span>' +
'<span class="lib-meta">' + esc(label ? progressText(b) : (b.author || progressText(b))) + '</span></div>';
}
function seriesCard(g) {
const total = g.books.reduce((n, b) => n + (b.total_seconds || 0), 0);
const heard = g.books.reduce((n, b) => n + (b.heard_seconds || 0), 0);
const heardSecs = g.books.reduce((n, b) => n + (b.heard_seconds || 0), 0);
const going = g.books.filter(b => b.next && b.last_played_at)[0];
const done = g.books.filter(b => !b.next).length;
const done = g.books.filter(heard).length;
const meta = g.books.length + ' Bände' + (going ? ' · bei ' + going._volume
: done === g.books.length ? ' · fertig gehört' : done ? ' · ' + done + ' gehört' : '');
return '<button type="button" class="ab-card ab-stack" data-ab-series="' + esc(g.key) + '">' +
'<span class="ab-stack-covers">' + cover(g.books[0].artwork_url, g.name, 'ab-cover') + '</span>' +
'<span class="ab-card-text"><span class="ab-title">' + esc(g.name) + '</span>' +
(g.books[0].author ? '<span class="ab-meta">' + esc(g.books[0].author) + '</span>' : '') +
'<span class="ab-meta">' + esc(meta) + '</span>' +
'<span class="ab-bar"><span style="width:' + (total ? Math.round(100 * heard / total) : 0) + '%"></span></span></span></button>';
return '<div class="lib-card lib-stack" data-ab-series="' + esc(g.key) + '">' +
'<span class="lib-coverbox lib-stackbox">' + cover(g.books[0].artwork_url, g.name, 'lib-cover', g.books[0].author) + '</span>' +
'<span class="lib-bar"><span style="width:' + (total ? Math.round(100 * heardSecs / total) : 0) + '%"></span></span>' +
'<span class="lib-title">' + esc(g.name) + '</span>' +
'<span class="lib-meta">' + esc(meta) + '</span></div>';
}
const foldersOf = b => (b.folders && b.folders.length ? b.folders : (b.folder ? [b.folder] : []));
@ -311,32 +379,24 @@
return {folders: names.map(n => ({name: n, count: counts.get(n)})), none: names.length ? none : 0};
}
function gridHtml(books) {
const here = books.filter(b => st.folder == null ? true
: st.folder === '' ? !foldersOf(b).length : foldersOf(b).indexOf(st.folder) >= 0);
const list = entries(here);
if (st.series != null) {
const g = list.filter(e => e.kind === 'series' && e.key === st.series)[0];
if (g) {
return '<div class="ab-place"><button type="button" class="ab-back" data-ab="up">‹ Hörbücher</button>' +
'<strong>' + esc(g.name) + '</strong></div>' +
'<div class="ab-grid">' + g.books.map(b => bookCard(b, b._volume)).join('') + '</div>';
}
st.series = null;
}
return '<div class="ab-chips"></div><div class="ab-grid">' +
list.map(e => e.kind === 'series' ? seriesCard(e) : bookCard(e.b)).join('') + '</div>';
}
function drawChips(books) {
const el = shelf.querySelector('.ab-chips');
const el = shelf.querySelector('.lib-chips');
if (!el || !window.DioraFolders) return;
if (!books.length) { el.innerHTML = ''; return; }
const c = folderChips(books);
if (!c.folders.length) { el.innerHTML = ''; return; }
window.DioraFolders.chips(el, {
lead: [
{id: 'started', label: 'Angefangen', count: books.filter(started).length, on: st.status === 'started'},
{id: 'unheard', label: 'Ungehört', count: books.filter(unheard).length, on: st.status === 'unheard'},
{id: 'heard', label: 'Gehört', count: books.filter(heard).length, on: st.status === 'heard'},
],
folders: c.folders, active: st.folder, none: c.none,
onPick(name) { st.folder = name; renderShelf(); },
onManage: manageFolders,
onPick(p) {
if (p && p.lead) st.status = st.status === p.lead ? 'all' : p.lead;
else st.folder = p;
renderShelf();
},
onManage: c.folders.length ? manageFolders : null,
});
}
@ -442,7 +502,7 @@
const d = st.detail && st.detail.book.feed_id === st.open ? st.detail : null;
const fromList = (st.books || []).filter(b => b.feed_id === st.open)[0];
const b = d ? d.book : fromList;
if (!b) { view.innerHTML = '<div class="ab-top"><button type="button" class="ab-back" data-ab="close">‹ Bücher</button></div><p class="ab-empty">Wird geladen …</p>'; return; }
if (!b) { view.innerHTML = '<div class="ab-top"><button type="button" class="ab-back" data-ab="close">‹ Hörbücher</button></div><p class="ab-empty">Wird geladen …</p>'; return; }
const now = nowInBook();
const marks = d ? d.bookmarks : [];
const dlMap = d ? downloads() : null;
@ -452,7 +512,7 @@
return ch ? 'Kapitel ' + (ch.index + 1) : '';
};
view.innerHTML =
'<div class="ab-top"><button type="button" class="ab-back" data-ab="close">‹ Bücher</button>' +
'<div class="ab-top"><button type="button" class="ab-back" data-ab="close">‹ Hörbücher</button>' +
'<button type="button" class="ab-icon" data-ab="menu" aria-label="Optionen">⋮</button></div>' +
'<div class="ab-menu" hidden>' +
'<button type="button" data-ab="unplayed">Alles als ungehört markieren</button>' +
@ -763,8 +823,9 @@
}
shelf.addEventListener('click', e => {
const t = e.target.closest('button, .ab-continue');
const t = e.target.closest('button, .lib-continue, .lib-card');
if (!t) return;
if (t.dataset.ab === 'search') { st.searching = true; renderShelf(); return; }
if (t.dataset.abGo != null) { e.stopPropagation(); goOn(Number(t.dataset.abGo)); return; }
if (t.dataset.abOpen != null) { openBook(Number(t.dataset.abOpen)); return; }
if (t.dataset.abSeries != null) { st.series = t.dataset.abSeries; renderShelf(); return; }
@ -792,7 +853,18 @@
if (t.dataset.ab === 'pick') { st.picking = !st.picking; st.pickFocus = st.picking; renderShelf(); return; }
if (t.dataset.ab === 'pick-close') { st.picking = false; renderShelf(); }
});
shelf.addEventListener('change', e => {
if (!e.target.classList.contains('lib-sort')) return;
st.sort = e.target.value;
cached('sort', st.sort);
renderShelf();
});
shelf.addEventListener('input', e => {
if (e.target.classList.contains('lib-q')) {
st.q = e.target.value;
renderShelf();
return;
}
if (!e.target.classList.contains('ab-pick-q')) return;
st.pickQuery = e.target.value;
const box = shelf.querySelector('.ab-pick-list');

View file

@ -603,59 +603,35 @@
// ---- article view
let reader = null; // the overlay element
// ---- reading: an article opens in the book reader (65-article.js, DioraArticle) —
// the same typography, colours, pages, search, read-aloud and full screen as books.
let readerList = []; // what "next" walks through (blog items)
let readerStory = null;
let readerItem = null;
function fontSize() {
let v = 18;
try { v = parseInt(localStorage.getItem('diora_feeds_font') || '18', 10) || 18; } catch (e) {}
return Math.max(14, Math.min(26, v));
}
function ensureReader() {
if (reader) return reader;
reader = document.createElement('div');
reader.id = 'fd-article';
reader.hidden = true;
reader.addEventListener('click', onReaderClick);
reader.addEventListener('scroll', () => {
clearTimeout(progressTimer);
progressTimer = setTimeout(saveProgress, 1200);
}, {passive: true});
document.body.appendChild(reader);
return reader;
}
let reading = false; // an article of ours is in the reader
function readerOpen() {
return !!(reader && !reader.hidden);
return reading && !!(window.DioraArticle && DioraArticle.isOpen());
}
function closeReader() {
if (!reader) return;
saveProgress();
reader.hidden = true;
reader.innerHTML = '';
document.documentElement.classList.remove('fd-reading');
if (window.DioraArticle) DioraArticle.close();
}
function articleClosed() {
reading = false;
readerItem = readerStory = null;
render();
}
// How far the open article is read (0-1); saved now and then and when leaving it.
// How far the open article is read (0-1, from the reader); saved now and then and when leaving it.
let progressSent = 0;
let progressTimer = null;
function readProgress() {
if (!reader || reader.hidden) return 0;
const room = reader.scrollHeight - reader.clientHeight;
return room <= 40 ? 1 : Math.min(1, reader.scrollTop / room);
}
function saveProgress() {
function saveProgress(f) {
const i = readerItem;
if (!i) return;
const p = Math.round(readProgress() * 100) / 100;
const p = Math.round(f * 100) / 100;
if (Math.abs(p - progressSent) < 0.02 && !(p >= 0.9 && !i.read)) return;
progressSent = p;
i.progress = p;
@ -667,51 +643,68 @@
api('/feeds/items/' + i.id + '/progress/', {progress: p}).catch(() => {});
}
async function openItem(id, story) {
ensureReader();
if (readerItem) saveProgress();
readerStory = story || null;
reader.hidden = false;
document.documentElement.classList.add('fd-reading');
reader.innerHTML = readerShell('<p class="fd-note">Lädt …</p>');
reader.scrollTop = 0;
try {
const data = await api('/feeds/items/' + id + '/');
readerItem = data.item;
reader.innerHTML = readerShell(articleHtml(readerItem));
reader.scrollTop = 0;
if (readerStory) loadOverview(readerStory);
progressSent = readerItem.progress || 0;
if (readerItem.kind === 'news') {
// News is read once opened; a blog post once read to 90 % (the server keeps where).
if (!readerItem.read) {
readerItem.read = true;
markRead([id], true);
}
} else {
if (progressSent > 0.02 && progressSent < 0.9) {
// Back where reading stopped (on any device).
requestAnimationFrame(() => { reader.scrollTop = progressSent * (reader.scrollHeight - reader.clientHeight); });
} else {
setTimeout(saveProgress, 1500); // short enough to fit: read at once
}
}
} catch (e) {
reader.innerHTML = readerShell('<p class="fd-error">' + esc(e.message === 'Failed to fetch' ? 'Keine Verbindung.' : e.message) + '</p>');
}
// Opens a link outside, like the article's own links (the app hands them to the browser).
function openOutside(url) {
const a = document.createElement('a');
a.href = url;
a.target = '_blank';
a.rel = 'noopener';
document.body.appendChild(a);
a.click();
a.remove();
}
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>' : '') +
// The article's own buttons, in the reader's header (the apps: in its menu).
function articleActions(i) {
return [
{icon: i.starred ? '★' : '☆', label: i.starred ? 'Gemerkt' : 'Merken', active: !!i.starred, action: () => toggleStar(i)},
// Projekte (projects.js): this article as a source of a project.
(i && window.DioraProjects ? '<button type="button" class="fd-icon" data-r="project" aria-label="Mit Projekt verknüpfen" title="Mit Projekt verknüpfen">◇</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>';
window.DioraProjects && {icon: '◇', label: 'Mit Projekt verknüpfen', action: () => window.DioraProjects.link('article', i.id, {
title: i.title, source: i.source_title, author: i.author, date: i.published, url: i.url, news: i.kind === 'news'})},
i.url && {icon: '↗', label: 'Im Browser öffnen', action: () => openOutside(i.url)},
];
}
function toggleStar(i) {
i.starred = !i.starred;
api('/feeds/items/' + i.id + '/star/', {starred: i.starred}).catch(() => {});
st.items.forEach(x => { if (x.id === i.id) x.starred = i.starred; });
if (window.DioraArticle) DioraArticle.refreshActions();
}
async function openItem(id, story) {
if (!window.DioraArticle) return;
readerStory = story || null;
let item = null;
let error = '';
try {
item = (await api('/feeds/items/' + id + '/')).item;
} catch (e) {
error = e.message === 'Failed to fetch' ? 'Keine Verbindung.' : e.message;
}
readerItem = item;
reading = true;
if (!item) {
DioraArticle.open({title: 'Artikel', html: '<p class="fd-error">' + esc(error) + '</p>',
onClick: onReaderClick, onClose: articleClosed});
return;
}
progressSent = item.progress || 0;
await DioraArticle.open({
title: item.source_title || item.title,
html: articleHtml(item),
// News is read once opened; a blog post once read to 90 % (the server keeps where).
startFraction: item.kind === 'news' ? 0 : progressSent,
onProgress: f => { if (readerItem === item) saveProgress(f); },
onClick: onReaderClick,
onClose: articleClosed,
actions: () => articleActions(item),
});
if (readerStory) loadOverview(readerStory);
if (item.kind === 'news' && !item.read) {
item.read = true;
markRead([id], true);
}
}
function articleHtml(i) {
@ -787,7 +780,7 @@
} catch (e) {
return; // offline and never fetched: the article alone
}
const box = reader && !reader.hidden && readerStory === story && reader.querySelector('.fd-ov');
const box = readerOpen() && readerStory === story && document.querySelector('#reader-content .fd-ov');
if (box) box.innerHTML = overviewHtml(story);
}
@ -807,37 +800,16 @@
});
}
// Clicks in the article (65-article.js hands them over): its other reports, the next article.
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;
}
if (!b) return;
const r = b.dataset.r;
if (r === 'close') closeReader();
else if (r === 'item') openItem(parseInt(b.dataset.id, 10), readerStory);
if (r === 'item') openItem(parseInt(b.dataset.id, 10), readerStory);
else if (r === 'story') {
const other = readerStory && readerStory.overview && (readerStory.overview.related || []).find(x => x.id === parseInt(b.dataset.id, 10));
if (other) openItem((other.lead || other.items[0]).id, other);
}
else if (r === 'font') {
let v = fontSize() + 2;
if (v > 26) v = 14;
try { localStorage.setItem('diora_feeds_font', String(v)); } catch (err) {}
const art = reader.querySelector('.fd-art');
if (art) art.style.fontSize = v + 'px';
} else if (r === 'project' && readerItem && window.DioraProjects) {
window.DioraProjects.link('article', readerItem.id, {title: readerItem.title, source: readerItem.source_title,
author: readerItem.author, date: readerItem.published, url: readerItem.url, news: readerItem.kind === 'news'});
} 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) {
@ -1354,9 +1326,6 @@ und Ordner. Schlage nur Änderungen vor, die wirklich helfen; eine Quelle ohne s
}
return false;
};
document.addEventListener('keydown', e => {
if (e.key === 'Escape' && readerOpen()) closeReader();
});
// diora Reader has no tab bar of its own: a small one. With the navigation (nav.js) it
// holds the sections of Lesen itself — a row of them to swipe is no good on e-ink.
@ -1436,9 +1405,9 @@ und Ordner. Schlage nur Änderungen vor, die wirklich helfen; eine Quelle ohne s
'.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-artview .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-artview .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-artview .fd-other-title { font-weight: 600; line-height: 1.35; }',
'.fd-chip { padding: 6px 12px; border: 1px solid var(--border, #2a2a2a); border-radius: 16px; background: none; color: inherit; font: inherit; font-size: 13px; cursor: pointer; }',
'.fd-chip.on { border-color: var(--accent, #e63946); color: var(--accent, #e63946); }',
'.fd-chip small { color: var(--nui-muted, #9a9a9a); font-size: 11px; }',
@ -1491,45 +1460,38 @@ und Ordner. Schlage nur Änderungen vor, die wirklich helfen; eine Quelle ohne s
'#tab-feeds .fd-opml-row { display: flex; justify-content: space-between; align-items: center; gap: 8px; }',
'#tab-feeds .fd-keep { display: flex; align-items: center; gap: 8px; margin-top: 14px; }',
'#tab-feeds .fd-file { display: inline-block; }',
// The article: a page over everything, like the book reader.
'#fd-article { position: fixed; top: 0; right: 0; bottom: 0; left: 0; z-index: 900; background: var(--bg, #000); color: var(--fg, #fff); overflow-y: auto; -webkit-overflow-scrolling: touch; }',
'#fd-article[hidden] { display: none; }',
'#fd-article .fd-art-bar { position: sticky; top: 0; display: flex; align-items: center; gap: 2px; padding: 4px 6px; background: var(--bg, #000); border-bottom: 1px solid var(--border, #2a2a2a); padding-top: calc(4px + env(safe-area-inset-top, 0px)); }',
'#fd-article .fd-art-src { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 13px; color: var(--nui-muted, #9a9a9a); }',
'#fd-article .fd-art { max-width: 68ch; margin: 0 auto; padding: 12px 18px 48px; font-family: "Source Serif 4", Georgia, serif; font-weight: 380; line-height: 1.65; }',
'#fd-article .fd-art-title { font-size: 1.45em; line-height: 1.25; margin: 10px 0 6px; font-weight: 700; }',
'#fd-article .fd-art-meta { font-family: var(--font); font-size: 12px; color: var(--nui-muted, #9a9a9a); margin: 0 0 14px; }',
'#fd-article .fd-art-img, #fd-article .fd-art-body img { display: block; max-width: 100%; height: auto; margin: 12px auto; border-radius: 4px; }',
'#fd-article .fd-art-body a { color: var(--accent, #e63946); }',
'#fd-article .fd-art-body figure { margin: 12px 0; }',
'#fd-article .fd-art-body figcaption { font-size: 0.8em; color: var(--nui-muted, #9a9a9a); }',
'#fd-article .fd-art-body blockquote { margin: 12px 0; padding-left: 14px; border-left: 3px solid var(--border-strong, #444); }',
'#fd-article .fd-art-body pre { white-space: pre-wrap; font-size: 0.85em; }',
'#fd-article .fd-art-body table { display: block; overflow-x: auto; border-collapse: collapse; font-size: 0.85em; }',
'#fd-article .fd-art-body td, #fd-article .fd-art-body th { border: 1px solid var(--border, #333); padding: 4px 6px; }',
'#fd-article .fd-sources { font-family: var(--font); }',
// The article, in the book reader (65-article.js).
'.fd-artview .fd-art-title { font-size: 1.45em; line-height: 1.25; margin: 10px 0 6px; font-weight: 700; }',
'.fd-artview .fd-art-meta { font-family: var(--font); font-size: 12px; color: var(--nui-muted, #9a9a9a); margin: 0 0 14px; }',
'.fd-artview .fd-art-img, .fd-artview .fd-art-body img { display: block; max-width: 100%; height: auto; margin: 12px auto; border-radius: 4px; }',
'.fd-artview .fd-art-body a { color: var(--accent, #e63946); }',
'.fd-artview .fd-art-body figure { margin: 12px 0; }',
'.fd-artview .fd-art-body figcaption { font-size: 0.8em; color: var(--nui-muted, #9a9a9a); }',
'.fd-artview .fd-art-body blockquote { margin: 12px 0; padding-left: 14px; border-left: 3px solid var(--border-strong, #444); }',
'.fd-artview .fd-art-body pre { white-space: pre-wrap; font-size: 0.85em; }',
'.fd-artview .fd-art-body table { display: block; overflow-x: auto; border-collapse: collapse; font-size: 0.85em; }',
'.fd-artview .fd-art-body td, .fd-artview .fd-art-body th { border: 1px solid var(--border, #333); padding: 4px 6px; }',
'.fd-artview .fd-sources { font-family: var(--font); }',
'#tab-feeds .fd-topics .fd-note { margin-right: 2px; }',
'#tab-feeds .fd-topics small { opacity: 0.6; }',
'#fd-article .fd-ov { font-size: 0.9em; }',
'#fd-article .fd-ov-box { margin: 0 0 18px; padding: 10px 14px; border: 1px solid var(--border, #2a2a2a); border-radius: 6px; }',
'#fd-article .fd-ov-box summary { font-family: var(--font); font-size: 12px; text-transform: uppercase; letter-spacing: 0.08em; color: var(--nui-muted, #9a9a9a); cursor: pointer; }',
'#fd-article .fd-ov p { margin: 8px 0; line-height: 1.45; }',
'#fd-article .fd-ov h4 { font-family: var(--font); font-size: 12px; font-weight: 400; text-transform: uppercase; letter-spacing: 0.08em; color: var(--nui-muted, #9a9a9a); margin: 14px 0 4px; }',
'#fd-article .fd-ov-src { font-family: var(--font); font-size: 11px; color: var(--nui-muted, #9a9a9a); white-space: nowrap; }',
'#fd-article .fd-ov-words { font-family: var(--font); font-size: 13px; }',
'#fd-article .fd-ov-rel { margin: 0 0 18px; }',
'#fd-article .fd-art-foot { display: flex; flex-wrap: wrap; gap: 10px; justify-content: space-between; margin-top: 28px; font-family: var(--font); }',
'html.fd-reading #nui-dock { display: none; }',
'.fd-artview .fd-ov { font-size: 0.9em; }',
'.fd-artview .fd-ov-box { margin: 0 0 18px; padding: 10px 14px; border: 1px solid var(--border, #2a2a2a); border-radius: 6px; }',
'.fd-artview .fd-ov-box summary { font-family: var(--font); font-size: 12px; text-transform: uppercase; letter-spacing: 0.08em; color: var(--nui-muted, #9a9a9a); cursor: pointer; }',
'.fd-artview .fd-ov p { margin: 8px 0; line-height: 1.45; }',
'.fd-artview .fd-ov h4 { font-family: var(--font); font-size: 12px; font-weight: 400; text-transform: uppercase; letter-spacing: 0.08em; color: var(--nui-muted, #9a9a9a); margin: 14px 0 4px; }',
'.fd-artview .fd-ov-src { font-family: var(--font); font-size: 11px; color: var(--nui-muted, #9a9a9a); white-space: nowrap; }',
'.fd-artview .fd-ov-words { font-family: var(--font); font-size: 13px; }',
'.fd-artview .fd-ov-rel { margin: 0 0 18px; }',
'.fd-artview .fd-art-foot { display: flex; flex-wrap: wrap; gap: 10px; justify-content: space-between; margin-top: 28px; font-family: var(--font); }',
'@media (hover: hover) { #tab-feeds .fd-card:hover, #tab-feeds .fd-row:hover, #tab-feeds .fd-post:hover, #tab-feeds .fd-src:hover { background: var(--hover, #111); } }',
// Bright pages and e-ink: black on white, no grey boxes, no thumbnails to flicker.
'body.bright-bg #tab-feeds .fd-brief, body.eink #tab-feeds .fd-brief { background: #fff; }',
'body.eink #tab-feeds .fd-thumb { display: none; }',
'body.eink #tab-feeds .fd-seg button.on, body.eink #tab-feeds .fd-badge { background: #000; color: #fff; }',
'body.eink .fd-row-meta, body.eink .fd-note, body.eink #tab-feeds .fd-card-sum, body.eink #tab-feeds .fd-read .fd-card-title, body.eink #tab-feeds .fd-read .fd-row-title { color: #333; }',
'body.eink #fd-article, body.eink #fd-article .fd-art-bar { background: #fff; color: #000; }',
'body.eink #fd-article .fd-ov-box { border-color: #000; }',
'body.eink #fd-article .fd-ov-src, body.eink #fd-article .fd-ov h4, body.eink #fd-article .fd-ov-box summary { color: #333; }',
'body.eink #fd-article .fd-art-body a, body.eink .fd-chip.on, body.eink .fd-icon.on, body.eink #tab-feeds .fd-brief-head h2 { color: #000; }',
'body.eink .fd-artview .fd-ov-box { border-color: #000; }',
'body.eink .fd-artview .fd-ov-src, body.eink .fd-artview .fd-ov h4, body.eink .fd-artview .fd-ov-box summary { color: #333; }',
'body.eink .fd-artview .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);
})();

View file

@ -2,7 +2,7 @@
* diora service worker — caches the app shell for offline use.
*/
const CACHE = 'diora-v157';
const CACHE = 'diora-v158';
const SHELL = [
'/static/css/app.css',
'/static/js/app/01-core.js',
@ -32,6 +32,7 @@ const SHELL = [
'/static/js/app/60-tts.js',
'/static/js/app/62-reader.js',
'/static/js/app/63-pages.js',
'/static/js/app/65-article.js',
'/static/js/app/64-reader-settings.js',
'/static/js/app/66-pdf-paginated.js',
'/static/js/app/68-bookmarks.js',

View file

@ -388,6 +388,7 @@
<script src="/static/js/app/62-reader.js"></script>
<script src="/static/js/app/63-pages.js"></script>
<script src="/static/js/app/64-reader-settings.js"></script>
<script src="/static/js/app/65-article.js"></script>
<script src="/static/js/app/66-pdf-paginated.js"></script>
<script src="/static/js/app/68-bookmarks.js"></script>
<script src="/static/js/app/70-search.js"></script>