diff --git a/e2e/global-setup.js b/e2e/global-setup.js index cb36a8a..581f302 100644 --- a/e2e/global-setup.js +++ b/e2e/global-setup.js @@ -47,6 +47,10 @@ User.objects.create_user(${JSON.stringify(TEST_USER.username)}, "", ${JSON.strin fs.mkdirSync(AUTH_DIR, { recursive: true }); // The book the reader tests read (e2e/make_epub.py). execFileSync(PYTHON, [path.join(__dirname, 'make_epub.py'), path.join(AUTH_DIR, 'book.epub')], { stdio: 'inherit' }); + // Two more for uploading several at once (navigation.spec.js). + for (const n of ['Eins', 'Zwei']) { + execFileSync(PYTHON, [path.join(__dirname, 'make_epub.py'), path.join(AUTH_DIR, `many-${n}.epub`), `Mehrfach ${n}`], { stdio: 'inherit' }); + } const browser = await chromium.launch(); const page = await browser.newPage({ baseURL: BASE_URL }); diff --git a/e2e/make_epub.py b/e2e/make_epub.py index 6f9d905..df99cd9 100644 --- a/e2e/make_epub.py +++ b/e2e/make_epub.py @@ -28,7 +28,7 @@ def chapter(n):

Kapitel {n}

{''.join(paras)}{notes}""" -def main(path): +def main(path, title='E2E Testbuch'): chapters = range(1, 13) manifest = ''.join(f'' for n in chapters) spine = ''.join(f'' for n in chapters) @@ -37,7 +37,7 @@ def main(path): diora-e2e-book - E2E Testbuchdiorade + {title}diorade {manifest} {spine} @@ -55,4 +55,4 @@ def main(path): if __name__ == '__main__': - main(sys.argv[1]) + main(sys.argv[1], *sys.argv[2:3]) diff --git a/e2e/navigation.spec.js b/e2e/navigation.spec.js index cc987b0..9e675cc 100644 --- a/e2e/navigation.spec.js +++ b/e2e/navigation.spec.js @@ -33,7 +33,8 @@ test('narrow: three groups, the sections as a row on top, each group remembers i await page.setViewportSize({ width: 390, height: 800 }); await page.goto('/#news'); const narrow = page.locator('#tabs .nav-narrow'); - await expect(narrow.locator('[data-nav]')).toHaveText(['Home', 'Lesen', 'Hören', '⚙']); + await expect(narrow.locator('[data-nav]')).toHaveText(['Home', 'Lesen', 'Hören', '']); + await expect(narrow.locator('[data-nav="sources"] svg.dnav-ic')).toBeVisible(); await expect(narrow.locator('[data-nav="lesen"]')).toHaveClass(/active/); const segs = page.locator('#nav-segments'); await expect(segs.locator('[data-nav]')).toHaveText(['Bücher', 'Zeitschriften', 'Nachrichten', 'Blogs']); @@ -88,3 +89,18 @@ test('the library: a header with search, sort, layout and +, the add menu', asyn await page.goto('/#magazines'); await expect(list.locator('.lib-h')).toHaveText('Zeitschriften'); }); + +test('several books at once; one already there is left out', async ({ page }) => { + const files = ['Eins', 'Zwei'].map(n => path.join(__dirname, '.auth', `many-${n}.epub`)); + await page.goto('/#books'); + await page.waitForFunction(() => typeof _importMany === 'function' && Array.isArray(_lastBookListData)); + const status = page.locator('#book-upload-status'); + await page.locator('#book-file-input').setInputFiles(files); + await expect(status).toContainText('2 Bücher hinzugefügt', { timeout: 30000 }); + await page.waitForFunction(() => _lastBookListData.filter(b => /^Mehrfach/.test(b.title)).length === 2); + await page.locator('#book-file-input').setInputFiles(files); + await expect(status).toContainText('2 schon da', { timeout: 30000 }); + // Out again, so the other tests see the library as before. + const books = await page.evaluate(() => _lastBookListData.filter(b => /^Mehrfach/.test(b.title)).map(b => b.id)); + for (const id of books) await page.request.post(`/books/${id}/delete/`); +}); diff --git a/static/js/app/52-book-list.js b/static/js/app/52-book-list.js index ff8b34b..5ee4876 100644 --- a/static/js/app/52-book-list.js +++ b/static/js/app/52-book-list.js @@ -50,8 +50,8 @@ async function loadBookList() { try { const metaBuf = await decryptBytes(key, b.meta_iv, b.meta_ct); const meta = JSON.parse(new TextDecoder().decode(metaBuf)); - bookMetaCache[b.id] = {title: meta.title || '?', author: meta.author || '', type: meta.type || 'epub', filename: meta.filename || '', folder: meta.folder || '', folders: _foldersOfMeta(meta), isbn: meta.isbn || '', shelfTag: meta.shelfTag || '', pages: meta.pages || 0, magazine: meta.magazine}; - decrypted.push({id: b.id, title: meta.title || '?', author: meta.author || '', type: meta.type || 'epub', folder: meta.folder || '', folders: _foldersOfMeta(meta), isbn: meta.isbn || '', shelfTag: meta.shelfTag || '', pages: meta.pages || 0, magazine: meta.magazine, has_cover: !!b.has_cover, scroll_fraction: b.scroll_fraction, position_anchor: b.position_anchor || '', read_at: b.read_at == null ? null : b.read_at, device: b.device || '', furthest_fraction: b.furthest_fraction || 0, furthest_anchor: b.furthest_anchor || '', uploaded_at: b.uploaded_at, last_read: b.last_read || null, keyOk: true, is_read: !!b.is_read, has_highlights: !!b.has_highlights}); + bookMetaCache[b.id] = {title: meta.title || '?', author: meta.author || '', type: meta.type || 'epub', filename: meta.filename || '', folder: meta.folder || '', folders: _foldersOfMeta(meta), isbn: meta.isbn || '', shelfTag: meta.shelfTag || '', pages: meta.pages || 0, magazine: meta.magazine, cover: meta.cover}; + decrypted.push({id: b.id, title: meta.title || '?', author: meta.author || '', type: meta.type || 'epub', folder: meta.folder || '', folders: _foldersOfMeta(meta), isbn: meta.isbn || '', shelfTag: meta.shelfTag || '', pages: meta.pages || 0, magazine: meta.magazine, cover: meta.cover, has_cover: !!b.has_cover, scroll_fraction: b.scroll_fraction, position_anchor: b.position_anchor || '', read_at: b.read_at == null ? null : b.read_at, device: b.device || '', furthest_fraction: b.furthest_fraction || 0, furthest_anchor: b.furthest_anchor || '', uploaded_at: b.uploaded_at, last_read: b.last_read || null, keyOk: true, is_read: !!b.is_read, has_highlights: !!b.has_highlights}); } catch (e) { bookMetaCache[b.id] = {title: `Buch #${b.id}`, author: '', type: 'epub', filename: '', folder: '', isbn: '', shelfTag: ''}; decrypted.push({id: b.id, title: `Buch #${b.id}`, author: '', type: 'epub', folder: '', isbn: '', shelfTag: '', scroll_fraction: b.scroll_fraction, position_anchor: b.position_anchor || '', read_at: b.read_at == null ? null : b.read_at, device: b.device || '', furthest_fraction: b.furthest_fraction || 0, furthest_anchor: b.furthest_anchor || '', uploaded_at: b.uploaded_at, last_read: b.last_read || null, keyOk: false, is_read: !!b.is_read, has_highlights: !!b.has_highlights}); @@ -68,7 +68,7 @@ async function loadBookList() { const cachedBooks = (await _loadCachedBookMeta()).map(_withPendingProgress); if (cachedBooks.length) { cachedBooks.sort((a, b) => (_bookRecency(b) - _bookRecency(a)) || (b.uploaded_at || '').localeCompare(a.uploaded_at || '')); - for (const b of cachedBooks) bookMetaCache[b.id] = {title: b.title, author: b.author, type: b.type || 'epub', filename: b.filename || '', folder: b.folder || '', folders: _foldersOfMeta(b), isbn: b.isbn || '', shelfTag: b.shelfTag || '', pages: b.pages || 0, magazine: b.magazine}; + for (const b of cachedBooks) bookMetaCache[b.id] = {title: b.title, author: b.author, type: b.type || 'epub', filename: b.filename || '', folder: b.folder || '', folders: _foldersOfMeta(b), isbn: b.isbn || '', shelfTag: b.shelfTag || '', pages: b.pages || 0, magazine: b.magazine, cover: b.cover}; const uploadArea = $('book-upload-area'); if (uploadArea) uploadArea.style.display = 'none'; renderBookList(cachedBooks); @@ -156,6 +156,7 @@ function repairBook(bookId) { folders: prevMeta.folders || _foldersOfMeta(prevMeta), shelfTag: prevMeta.shelfTag || '', magazine: prevMeta.magazine, + cover: prevMeta.cover, })); const [metaEnc, dataEnc] = await Promise.all([ encryptBytes(key, metaJson), @@ -245,6 +246,19 @@ async function toggleBookMagazine(bookId) { } window.toggleBookMagazine = toggleBookMagazine; +// The generated cover instead of the book's own (or back): a choice per book. +async function toggleBookCover(bookId) { + const book = _lastBookListData.find(b => b.id === bookId); + if (!book) return; + try { + await _updateBookMeta(bookId, {cover: book.cover === 'generated' ? '' : 'generated'}); + renderBookList(_lastBookListData); + } catch (e) { + customAlert('Das ließ sich nicht speichern: ' + e.message); + } +} +window.toggleBookCover = toggleBookCover; + // The books of the section shown (all of them where there are no sections). function _bookSectionFilter(books) { if (!window.DioraNav) return books; @@ -423,6 +437,7 @@ async function _updateBookMeta(bookId, overrides) { shelfTag: cached.shelfTag || book.shelfTag || '', pages: cached.pages || book.pages || 0, magazine: cached.magazine !== undefined ? cached.magazine : book.magazine, + cover: cached.cover !== undefined ? cached.cover : book.cover, ...overrides, }; // The list decides; `folder` follows it (the first), for older clients. @@ -559,10 +574,43 @@ async function lookupBookMetadata(bookId) { } function bookFileSelected(input) { - const file = input.files[0]; - if (!file) return; - uploadEbook(file); + const files = Array.from(input.files || []); input.value = ''; + if (files.length === 1) uploadEbook(files[0]); + else if (files.length) _importMany(files.map(f => ({name: f.name, size: f.size, buf: () => f.arrayBuffer()}))); +} + +// Several books, one after the other (a picked set of files, a cloud folder): each is +// read, encrypted and sent like a single one; a book already in the library (same title +// and author) is left out. statusEl shows how far it is and, at the end, what came of it. +async function _importMany(items, statusEl) { + statusEl = statusEl || $('book-upload-status'); + const done = {ok: 0, dup: 0, failed: []}; + for (let i = 0; i < items.length; i++) { + const it = items[i]; + if (statusEl) statusEl.textContent = `Buch ${i + 1} von ${items.length}: „${it.name}“ …`; + if (it.size && it.size > DIORA_CONFIG.ebookMaxBytes) { done.failed.push(it.name + ' (zu groß)'); continue; } + try { + const r = await _importEbookBuffer(await it.buf(), it.name, null, {reload: false, skipKnown: true}); + if (r === 'dup') done.dup++; + else if (r) done.ok++; + else done.failed.push(it.name); + } catch (e) { + done.failed.push(it.name); + } + } + const parts = [`✓ ${done.ok} ${done.ok === 1 ? 'Buch' : 'Bücher'} hinzugefügt`]; + if (done.dup) parts.push(`${done.dup} schon da`); + if (done.failed.length) parts.push(`nicht geklappt: ${done.failed.join(', ')}`); + if (statusEl) statusEl.textContent = parts.join(' · '); + if (done.ok) loadBookList(); + return done; +} + +// Is a book with this title and author in the library already? +function _knownBook(title, author) { + const n = s => String(s || '').normalize('NFD').replace(/[\u0300-\u036f]/g, '').toLowerCase().replace(/\s+/g, ' ').trim(); + return (_lastBookListData || []).some(b => n(b.title) === n(title) && n(b.author) === n(author)); } function initBookDropZone() { @@ -584,8 +632,9 @@ function initBookDropZone() { zone.addEventListener('drop', e => { e.preventDefault(); zone.classList.remove('drag-over'); - const file = e.dataTransfer.files[0]; - if (file) uploadEbook(file); + const files = Array.from(e.dataTransfer.files || []); + if (files.length === 1) uploadEbook(files[0]); + else if (files.length) _importMany(files.map(f => ({name: f.name, size: f.size, buf: () => f.arrayBuffer()}))); }); } @@ -630,7 +679,8 @@ async function uploadEbook(file) { // Shared tail of every import path (local file, cloud): parse metadata, encrypt // with the user's key and POST ciphertext. Plaintext never leaves the browser. -async function _importEbookBuffer(buf, filename, statusEl) { +async function _importEbookBuffer(buf, filename, statusEl, opts) { + opts = opts || {}; const isPdf = /\.pdf$/i.test(filename); const isEpub = /\.epub$/i.test(filename); if (!isPdf && !isEpub) { @@ -673,6 +723,7 @@ async function _importEbookBuffer(buf, filename, statusEl) { } catch (e) { /* use filename as title */ } } + if (opts.skipKnown && _knownBook(title, author)) return 'dup'; const key = await getOrCreateEncKey(); const metaJson = new TextEncoder().encode(JSON.stringify({title, author, filename, type, isbn, folder: '', shelfTag: ''})); const [metaEnc, dataEnc] = await Promise.all([ @@ -695,7 +746,8 @@ async function _importEbookBuffer(buf, filename, statusEl) { const data = await res.json(); if (data.ok) { if (statusEl) statusEl.textContent = `✓ „${title}“ hochgeladen`; - loadBookList(); + if (opts.reload !== false) loadBookList(); + else _lastBookListData.push({title, author}); // known for the next of a set return true; } if (statusEl) statusEl.textContent = 'Fehler: ' + (data.error ? _germanError(data.error) : 'Hochladen fehlgeschlagen'); diff --git a/static/js/app/53-library.js b/static/js/app/53-library.js index a826528..46b7a8a 100644 --- a/static/js/app/53-library.js +++ b/static/js/app/53-library.js @@ -98,7 +98,7 @@ function _libHue(s) { } function _libCover(b, cls) { - const url = window.DioraCovers ? DioraCovers.url(b) : null; + const url = window.DioraCovers && b.cover !== 'generated' ? DioraCovers.url(b) : null; if (url) return ``; // None (yet): one drawn from the book's title, author, category and folders (gencover.js). if (window.DioraGenCover) { @@ -303,6 +303,7 @@ function _libSheet(b) { b.keyOk !== false && ['Lesen', () => openBook(b.id)], ['In Ordner …', () => _libAssign([b.id])], [_isMagazine(b) ? 'Als Buch führen' : 'Als Zeitschrift führen', () => toggleBookMagazine(b.id)], + [b.cover === 'generated' ? 'Eigenes Cover verwenden' : 'Generiertes Cover verwenden', () => toggleBookCover(b.id)], [b.is_read ? 'Als ungelesen markieren' : 'Als gelesen markieren', () => toggleBookRead(b.id, !!b.is_read)], b.has_highlights && ['Markierungen exportieren', () => downloadBookAnnotationsFromList(b.id, null)], ['Regal-Kategorie abrufen', () => lookupBookMetadata(b.id)], @@ -405,6 +406,8 @@ function _libSheet(b) { }; if (window.DioraCovers) { DioraCovers.onReady(id => { + const b = (_lastBookListData || []).find(x => x.id === id); + if (b && b.cover === 'generated') return; const url = DioraCovers.url({id}); document.querySelectorAll(`#book-list [data-cover-id="${id}"]`).forEach(el => { if (el.tagName === 'IMG') { el.src = url; return; } @@ -418,6 +421,22 @@ function _libSheet(b) { }); } window.addEventListener('resize', () => { if (_lastBookListData && _lastBookListData.length) renderBookList(_lastBookListData); }); + // Files dropped anywhere on the library (the old drop zone is hidden): one or several. + const tabEl = $('tab-books'); + if (tabEl) { + tabEl.addEventListener('dragover', e => { + if (e.dataTransfer && Array.from(e.dataTransfer.types || []).indexOf('Files') >= 0) { e.preventDefault(); tabEl.classList.add('lib-drop'); } + }); + tabEl.addEventListener('dragleave', e => { if (e.target === tabEl) tabEl.classList.remove('lib-drop'); }); + tabEl.addEventListener('drop', e => { + tabEl.classList.remove('lib-drop'); + const files = Array.from((e.dataTransfer && e.dataTransfer.files) || []); + if (!files.length) return; + e.preventDefault(); + if (files.length === 1) uploadEbook(files[0]); + else _importMany(files.map(f => ({name: f.name, size: f.size, buf: () => f.arrayBuffer()}))); + }); + } _libLoadPrefs(); })(); @@ -496,6 +515,11 @@ function _libAddMenu() { '#book-list .lib-count { opacity: .7; }', '#book-list .lib-empty { color: var(--muted, #9a9a9a); font-size: 14px; }', 'body.eink #book-list .lib-add, body.eink #book-list .lib-play { background: #000; color: #fff; }', + '#tab-books.lib-drop { outline: 2px dashed var(--accent, #e63946); outline-offset: -6px; }', + '#book-upload-status { display: block; margin: 0 0 10px; color: var(--muted, #9a9a9a); font-size: 13px; }', + '.cloud-chosen { display: flex; align-items: center; justify-content: space-between; gap: 10px; margin: 6px 0; }', + '.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'); document.head.appendChild(style); diff --git a/static/js/app/56-cloud-import.js b/static/js/app/56-cloud-import.js index aa582b9..f217144 100644 --- a/static/js/app/56-cloud-import.js +++ b/static/js/app/56-cloud-import.js @@ -94,6 +94,49 @@ function _cloudEntryRow(label, onClick, sizeText) { return li; } +// Books chosen in this folder of the cloud (path -> entry), imported together. +let _cloudChosen = new Map(); + +function _cloudChosenBar() { + let bar = $('cloud-chosen'); + const listEl = $('cloud-entries'); + if (!bar && listEl) { + bar = document.createElement('div'); + bar.id = 'cloud-chosen'; + bar.className = 'cloud-chosen'; + listEl.parentNode.insertBefore(bar, listEl); + } + if (!bar) return; + const books = Array.from(listEl.querySelectorAll('.cloud-check')); + const n = _cloudChosen.size; + bar.innerHTML = books.length ? `` + + `` : ''; + const all = bar.querySelector('.cloud-all input'); + if (all) all.onchange = () => { + books.forEach(c => { c.checked = all.checked; c.onchange(); }); + }; + const go = bar.querySelector('button'); + if (go) go.onclick = () => _cloudImportChosen(); +} + +async function _cloudImportChosen() { + const entries = Array.from(_cloudChosen.values()); + if (!entries.length) return; + _cloudChosen = new Map(); + const statusEl = $('cloud-status'); + await _importMany(entries.map(entry => ({ + name: entry.name, + size: entry.size, + buf: async () => { + const res = await fetch(`/books/cloud/${_cloudSourceId}/fetch/?path=${encodeURIComponent(entry.path)}`); + if (!res.ok) throw new Error('HTTP ' + res.status); + return res.arrayBuffer(); + }, + })), statusEl); + $('cloud-entries').querySelectorAll('.cloud-check').forEach(c => { c.checked = false; }); + _cloudChosenBar(); +} + async function _cloudBrowse(path) { const listEl = $('cloud-entries'); const pathEl = $('cloud-path'); @@ -102,6 +145,8 @@ async function _cloudBrowse(path) { if (statusEl) statusEl.textContent = 'Lade…'; listEl.innerHTML = ''; + _cloudChosen = new Map(); + _cloudChosenBar(); let data; try { @@ -127,10 +172,21 @@ async function _cloudBrowse(path) { } for (const entry of entries) { - listEl.appendChild(entry.is_dir - ? _cloudEntryRow('📁 ' + entry.name, () => _cloudBrowse(entry.path)) - : _cloudEntryRow('📖 ' + entry.name, () => _cloudImport(entry), _formatBytes(entry.size))); + if (entry.is_dir) { listEl.appendChild(_cloudEntryRow('📁 ' + entry.name, () => _cloudBrowse(entry.path))); continue; } + // A book: its name imports it alone, its box chooses it for importing several. + const row = _cloudEntryRow('📖 ' + entry.name, () => _cloudImport(entry), _formatBytes(entry.size)); + const box = document.createElement('input'); + box.type = 'checkbox'; + box.className = 'cloud-check'; + box.setAttribute('aria-label', entry.name + ' auswählen'); + box.onchange = () => { + if (box.checked) _cloudChosen.set(entry.path, entry); else _cloudChosen.delete(entry.path); + _cloudChosenBar(); + }; + row.prepend(box); + listEl.appendChild(row); } + _cloudChosenBar(); } async function _cloudImport(entry) { diff --git a/static/js/feeds.js b/static/js/feeds.js index a29ea0c..8679d83 100644 --- a/static/js/feeds.js +++ b/static/js/feeds.js @@ -1120,7 +1120,7 @@ dock.id = 'nui-dock'; dock.innerHTML = ''; document.body.appendChild(dock); diff --git a/static/js/nav.js b/static/js/nav.js index b0af37b..fd14db3 100644 --- a/static/js/nav.js +++ b/static/js/nav.js @@ -37,6 +37,39 @@ const byId = {}; SECTIONS.forEach(s => { byId[s.id] = s; }); + // The icons (style B: bold and geometric, 2.4 px, square ends — at home with the monospace + // type and crisp on e-ink). A 24 grid; the parts marked "f" fill in when the section is + // the one shown (.active / .nui-active / .on around the icon). + const ICONS = { + home: '', + books: '' + + '', + magazines: '' + + '', + news: '', + blogs: '', + audiobooks: '' + + '' + + '', + podcasts: '' + + '', + radio: '' + + '', + ambient: '' + + '', + focus: '', + sources: '', + menu: '' + + '', + }; + ICONS.lesen = ICONS.books; + ICONS.hoeren = ICONS.audiobooks; + + function icon(name) { + const body = ICONS[name]; + return body ? '' : ''; + } + const nativeUi = () => html.classList.contains('nui-radio'); // the apps' own bars (native-radio.js) const readerApp = () => html.classList.contains('nui-reader-app'); const noRead = () => html.classList.contains('nui-noread'); // TV @@ -55,12 +88,13 @@ } function sections() { - return SECTIONS.filter(available).map(s => ({id: s.id, group: s.group, label: s.label, icon: s.icon, footer: !!s.footer})); + return SECTIONS.filter(available).map(s => ({id: s.id, group: s.group, label: s.label, icon: s.icon, svg: icon(s.id), + footer: !!s.footer})); } function groups() { const list = sections(); - return GROUPS.map(g => ({id: g.id, label: g.label, icon: g.icon, + return GROUPS.map(g => ({id: g.id, label: g.label, icon: g.icon, svg: icon(g.id), sections: list.filter(s => s.group === g.id && !s.footer).map(s => s.id)})).filter(g => g.sections.length); } @@ -203,7 +237,8 @@ '' + ''; + foot.map(s => '').join('') + ''; } if (segs) { const g = cur ? gs.filter(x => x.id === cur.group)[0] : null; @@ -249,6 +284,8 @@ groups, current: () => current, go, + // A section's or group's icon as SVG (also 'menu'); the bars draw it. + icon, onChange(fn) { if (typeof fn === 'function') listeners.push(fn); }, // For a bar drawn elsewhere (native-radio.js): it shows the sections itself. barsReady() { html.classList.add('nav-bars'); render(); }, @@ -269,6 +306,9 @@ '#tabs .nav-narrow { display: none; }', '#tabs .nav-foot { margin-left: auto; }', '#tabs .nav-narrow .nav-icon { margin-left: auto; }', + '.dnav-ic { width: 22px; height: 22px; flex: none; fill: none; stroke: currentColor; stroke-width: 2.4; stroke-linecap: square; stroke-linejoin: miter; vertical-align: middle; }', + '.dnav-ic .f { fill: none; stroke: none; }', + '.active .dnav-ic .f, .nui-active .dnav-ic .f, .on .dnav-ic .f { fill: currentColor; }', '#tabs .nav-group-label { margin: 0 4px 0 14px; font-size: 11px; text-transform: uppercase; letter-spacing: 0.08em; color: var(--muted, #9a9a9a); }', '@media (max-width: 720px) { #tabs .nav-wide { display: none; } #tabs .nav-narrow { display: flex; } }', '#nav-segments { display: flex; gap: 6px; overflow-x: auto; padding: 8px 0 12px; scrollbar-width: none; }', diff --git a/static/js/sw.js b/static/js/sw.js index f0cd94e..098dcc6 100644 --- a/static/js/sw.js +++ b/static/js/sw.js @@ -2,7 +2,7 @@ * diora service worker — caches the app shell for offline use. */ -const CACHE = 'diora-v120'; +const CACHE = 'diora-v121'; const SHELL = [ '/static/css/app.css', '/static/js/app/01-core.js', diff --git a/templates/radio/player.html b/templates/radio/player.html index 0c38ddf..33b16a4 100644 --- a/templates/radio/player.html +++ b/templates/radio/player.html @@ -231,7 +231,7 @@
.epub oder .pdf hierher ziehen oder - +