Nachrichten und Blogs im Buch-Reader, Doppelklick für Vollbild, Hörbücher wie die Bücher-Bibliothek
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:
parent
6bb774adb9
commit
e8b525a3de
11 changed files with 521 additions and 229 deletions
|
|
@ -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; }
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
})();
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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
173
static/js/app/65-article.js
Normal 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); });
|
||||
});
|
||||
})();
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
})();
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue