Neue Navigations-Icons (Stil B), mehrere Bücher auf einmal, generiertes Cover pro Buch wählbar
All checks were successful
Build and push Docker image / build (push) Successful in 14s
Test / test (push) Successful in 9m6s
Test / e2e (push) Successful in 2m45s

- Icons: eigene SVGs, kräftig und geometrisch (2,4 px, eckige Enden), in
  nav.js für alle Leisten (DioraNav.icon, sections()/groups() .svg); der
  aktive Bereich gefüllt.
- Hochladen: mehrere Dateien auf einmal (Auswahl oder auf die Bibliothek
  ziehen) und aus der Cloud per Häkchen („Alle auswählen“, „n importieren“);
  nacheinander mit Fortschritt, ein schon vorhandenes Buch (Titel und Autor)
  wird übersprungen.
- Pro Buch: „Generiertes Cover verwenden“ statt des eigenen (im verschlüsselten
  Meta, cover: 'generated').

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
mrwnslz 2026-10-16 21:21:10 +02:00
parent 4e4fac227c
commit 6d7fb4a849
10 changed files with 216 additions and 24 deletions

View file

@ -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 });

View file

@ -28,7 +28,7 @@ def chapter(n):
<body><h1>Kapitel {n}</h1>{''.join(paras)}{notes}</body></html>"""
def main(path):
def main(path, title='E2E Testbuch'):
chapters = range(1, 13)
manifest = ''.join(f'<item id="c{n}" href="ch{n}.xhtml" media-type="application/xhtml+xml"/>' for n in chapters)
spine = ''.join(f'<itemref idref="c{n}"/>' for n in chapters)
@ -37,7 +37,7 @@ def main(path):
<package xmlns="http://www.idpf.org/2007/opf" version="3.0" unique-identifier="id">
<metadata xmlns:dc="http://purl.org/dc/elements/1.1/">
<dc:identifier id="id">diora-e2e-book</dc:identifier>
<dc:title>E2E Testbuch</dc:title><dc:creator>diora</dc:creator><dc:language>de</dc:language>
<dc:title>{title}</dc:title><dc:creator>diora</dc:creator><dc:language>de</dc:language>
</metadata>
<manifest><item id="nav" href="nav.xhtml" media-type="application/xhtml+xml" properties="nav"/>{manifest}</manifest>
<spine>{spine}</spine>
@ -55,4 +55,4 @@ def main(path):
if __name__ == '__main__':
main(sys.argv[1])
main(sys.argv[1], *sys.argv[2:3])

View file

@ -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/`);
});

View file

@ -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');

View file

@ -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 `<img class="${cls}" src="${url}" alt="" data-cover-id="${b.id}">`;
// 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);

View file

@ -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 ? `<label class="cloud-all"><input type="checkbox" ${n && n === books.length ? 'checked' : ''}> Alle ${books.length} auswählen</label>` +
`<button type="button" class="btn btn-primary"${n ? '' : ' disabled'}>${n ? n + ' importieren' : 'Bücher anhaken'}</button>` : '';
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) {

View file

@ -1120,7 +1120,7 @@
dock.id = 'nui-dock';
dock.innerHTML = '<nav id="nui-tabbar">' + (NAV
? NAV.sections().filter(s => !s.footer).map(s => '<button type="button" data-nav="' + s.id + '"><span class="nui-tab-icon">' +
esc(s.icon) + '</span>' + esc(s.label) + '</button>').join('')
(s.svg || esc(s.icon)) + '</span>' + esc(s.label) + '</button>').join('')
: '<button type="button" data-tab="books"><span class="nui-tab-icon">▤</span>Bücher</button>' +
'<button type="button" data-tab="feeds"><span class="nui-tab-icon">☰</span>Feeds</button>') + '</nav>';
document.body.appendChild(dock);

View file

@ -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: '<path class="f" d="M6 10v10h12V10l-6-5z"/><path d="M4 11l8-7 8 7"/><path d="M6 10v10h12V10"/><path d="M10 20v-5h4v5"/>',
books: '<rect class="f" x="4" y="4" width="4" height="16" rx="1"/><rect x="4" y="4" width="4" height="16" rx="1"/>' +
'<rect x="9" y="6" width="4" height="14" rx="1"/><path d="M14.6 7.4l3.8-1 3 12.6-3.8 1z"/>',
magazines: '<rect class="f" x="5" y="3" width="14" height="5"/><rect x="5" y="3" width="14" height="18" rx="1.5"/>' +
'<path d="M5 8h14"/><path d="M8 12h8M8 15.5h8M8 19h4"/>',
news: '<path class="f" d="M7 8h7v4H7z"/><path d="M4 5h13v14H6a2 2 0 0 1-2-2z"/><path d="M17 9h3v8a2 2 0 0 1-2 2"/><path d="M7 8h7v4H7zM7 15h7"/>',
blogs: '<path class="f" d="M15 4l5 5-9 9H6v-5z"/><path d="M15 4l5 5-9 9H6v-5z"/><path d="M13 6l5 5"/><path d="M6 21h12"/>',
audiobooks: '<path d="M5 18v-6a7 7 0 0 1 14 0v6"/><rect class="f" x="3.5" y="13" width="4.5" height="7" rx="1.5"/>' +
'<rect x="3.5" y="13" width="4.5" height="7" rx="1.5"/><rect class="f" x="16" y="13" width="4.5" height="7" rx="1.5"/>' +
'<rect x="16" y="13" width="4.5" height="7" rx="1.5"/>',
podcasts: '<rect class="f" x="9" y="3" width="6" height="11" rx="3"/><rect x="9" y="3" width="6" height="11" rx="3"/>' +
'<path d="M5.5 11a6.5 6.5 0 0 0 13 0"/><path d="M12 17.5V21M9 21h6"/>',
radio: '<rect class="f" x="3" y="9" width="18" height="11" rx="2"/><rect x="3" y="9" width="18" height="11" rx="2"/>' +
'<circle cx="15.5" cy="14.5" r="2.6"/><path d="M6.5 13h4.5M6.5 16h4.5M7 9l9-5"/>',
ambient: '<path class="f" d="M3 14.5c2-2.6 4-2.6 6 0s4 2.6 6 0 4-2.6 6 0V20H3z"/>' +
'<path d="M3 9.5c2-2.6 4-2.6 6 0s4 2.6 6 0 4-2.6 6 0"/><path d="M3 14.5c2-2.6 4-2.6 6 0s4 2.6 6 0 4-2.6 6 0"/>',
focus: '<circle class="f" cx="12" cy="13" r="8"/><circle cx="12" cy="13" r="8"/><path d="M12 9v4l3 2M9 3h6"/>',
sources: '<circle class="f" cx="6" cy="18" r="2"/><circle cx="6" cy="18" r="1.6"/><path d="M5 11a8 8 0 0 1 8 8"/><path d="M5 5a14 14 0 0 1 14 14"/>',
menu: '<rect class="f" x="10.5" y="4" width="3" height="3"/><rect x="10.5" y="4" width="3" height="3"/>' +
'<rect x="10.5" y="10.5" width="3" height="3"/><rect x="10.5" y="17" width="3" height="3"/>',
};
ICONS.lesen = ICONS.books;
ICONS.hoeren = ICONS.audiobooks;
function icon(name) {
const body = ICONS[name];
return body ? '<svg class="dnav-ic" viewBox="0 0 24 24" aria-hidden="true">' + body + '</svg>' : '';
}
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 @@
'</div>' +
'<div class="nav-narrow">' + gs.map(g =>
button(g.id, g.label, 'tab-btn', !!cur && cur.group === g.id)).join('') +
foot.map(s => button(s.id, s.icon, 'tab-btn nav-icon', current === s.id)).join('') + '</div>';
foot.map(s => '<button type="button" class="tab-btn nav-icon' + (current === s.id ? ' active' : '') +
'" data-nav="' + s.id + '" aria-label="' + esc(s.label) + '">' + icon(s.id) + '</button>').join('') + '</div>';
}
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; }',

View file

@ -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',

View file

@ -231,7 +231,7 @@
<div id="book-upload-area">
<div class="book-drop-zone" id="book-drop-zone">
<span>.epub oder .pdf hierher ziehen oder <label for="book-file-input" style="cursor:pointer;text-decoration:underline;">auswählen</label></span>
<input type="file" id="book-file-input" accept=".epub,.pdf" style="display:none;" onchange="bookFileSelected(this)">
<input type="file" id="book-file-input" accept=".epub,.pdf" multiple style="display:none;" onchange="bookFileSelected(this)">
<span id="book-upload-status" class="muted"></span>
</div>
<button type="button" class="btn" style="margin:0.5rem 0;" onclick="toggleCloudImport()">☁ Aus Cloud importieren</button>