Bibliothek: Ausgaben – PDF und EPUB (oder zwei Übersetzungen) als ein Werk, in der Liste einmal mit ihren Formaten
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
2327eb06d5
commit
7f9d6fc22a
5 changed files with 199 additions and 7 deletions
|
|
@ -60,6 +60,21 @@ PDF: page 1), scales it to 240 px wide JPEG, **encrypts it with the user's key**
|
|||
returns `{cover_ct, cover_iv}`. Clients cache the decrypted image locally. The
|
||||
server never sees a cover.
|
||||
|
||||
### Editions
|
||||
|
||||
Two or more books can be **editions of one work** (the PDF scan and the EPUB, two
|
||||
translations …): the same random key in each one's encrypted meta, `work: "w…"`
|
||||
(`''` or missing = none). The list shows a work once — the edition read last, else an EPUB,
|
||||
else the first added — with its formats on the cover ("EPUB · PDF", in the list view after
|
||||
the author). Each edition keeps its own file, position and highlights.
|
||||
|
||||
- Joining: choose several books → "Als Ausgaben verbinden", or a book's ⋮ → "Als Ausgabe
|
||||
verbinden …" (other books listed, similar titles first, with a search).
|
||||
- ⋮ → "Ausgaben (n) …" lists the editions: open one, "Lösen" sets one apart (the last one
|
||||
left is set apart with it), "Weitere Ausgabe verbinden …".
|
||||
- For the apps' own book sheets: `window.bookEditions(id)` opens that sheet,
|
||||
`window.bookEditionsOf(id)` returns the editions (the book alone if it has none).
|
||||
|
||||
### Generated covers
|
||||
|
||||
A book without a cover (or one set to `cover: 'generated'` in its meta) gets one
|
||||
|
|
|
|||
|
|
@ -104,3 +104,42 @@ test('several books at once; one already there is left out', async ({ page }) =>
|
|||
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/`);
|
||||
});
|
||||
|
||||
test('editions: two books joined show once with their formats, set apart show twice', async ({ page }) => {
|
||||
await page.setViewportSize({ width: 1200, height: 800 });
|
||||
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));
|
||||
await page.locator('#book-file-input').setInputFiles(files);
|
||||
await page.waitForFunction(() => _lastBookListData.filter(b => /^Mehrfach/.test(b.title)).length === 2, null, { timeout: 30000 });
|
||||
const ids = await page.evaluate(() => _lastBookListData.filter(b => /^Mehrfach/.test(b.title)).map(b => b.id));
|
||||
const list = page.locator('#book-list');
|
||||
// New books: as cards under "Ungelesen" (not in the "Zuletzt gelesen" area on top).
|
||||
const unread = () => page.evaluate(() => { _lib.status = 'unread'; renderBookList(_lastBookListData); });
|
||||
await unread();
|
||||
const cards = () => list.locator('.lib-grid [data-lib-book]').filter({ hasText: 'Mehrfach' });
|
||||
await expect(cards()).toHaveCount(2);
|
||||
// Choose both, join them.
|
||||
await page.evaluate(ids => { _lib.selecting = new Set(ids); renderBookList(_lastBookListData); }, ids);
|
||||
await list.locator('[data-lib="sel-editions"]').click();
|
||||
await page.waitForFunction(ids => {
|
||||
const w = ids.map(id => _lastBookListData.find(b => b.id === id).work);
|
||||
return w[0] && w[0] === w[1];
|
||||
}, ids);
|
||||
await expect(cards()).toHaveCount(1);
|
||||
await expect(cards().locator('.lib-ed')).toHaveText('EPUB');
|
||||
// The work survives a reload (it is in the encrypted meta).
|
||||
await page.reload();
|
||||
await page.waitForFunction(() => Array.isArray(_lastBookListData) && _lastBookListData.length);
|
||||
await unread();
|
||||
await expect(cards()).toHaveCount(1);
|
||||
// Ausgaben: both listed; one set apart, and the other is on its own again.
|
||||
await cards().locator('.lib-more').click({ force: true });
|
||||
await page.locator('.dfo-sheet .lib-sheet-item', { hasText: 'Ausgaben (2)' }).click();
|
||||
await expect(page.locator('.dfo-sheet .lib-ed-row')).toHaveCount(2);
|
||||
await page.locator('.dfo-sheet [data-unlink]').first().click();
|
||||
await page.waitForFunction(ids => ids.every(id => !_lastBookListData.find(b => b.id === id).work), ids);
|
||||
await expect(cards()).toHaveCount(2);
|
||||
await page.evaluate(() => { _lib.status = 'all'; });
|
||||
for (const id of ids) await page.request.post(`/books/${id}/delete/`);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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, cover: meta.cover, series: meta.series, seriesIndex: meta.seriesIndex, issue: meta.issue, issueRead: meta.issueRead, issueAt: meta.issueAt};
|
||||
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, series: meta.series, seriesIndex: meta.seriesIndex, issue: meta.issue, issueRead: meta.issueRead, issueAt: meta.issueAt, 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, series: meta.series, seriesIndex: meta.seriesIndex, work: meta.work, issue: meta.issue, issueRead: meta.issueRead, issueAt: meta.issueAt};
|
||||
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, series: meta.series, seriesIndex: meta.seriesIndex, work: meta.work, issue: meta.issue, issueRead: meta.issueRead, issueAt: meta.issueAt, 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, cover: b.cover, series: b.series, seriesIndex: b.seriesIndex, issue: b.issue, issueRead: b.issueRead, issueAt: b.issueAt};
|
||||
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, series: b.series, seriesIndex: b.seriesIndex, work: b.work, issue: b.issue, issueRead: b.issueRead, issueAt: b.issueAt};
|
||||
const uploadArea = $('book-upload-area');
|
||||
if (uploadArea) uploadArea.style.display = 'none';
|
||||
renderBookList(cachedBooks);
|
||||
|
|
@ -159,6 +159,7 @@ function repairBook(bookId) {
|
|||
cover: prevMeta.cover,
|
||||
series: prevMeta.series,
|
||||
seriesIndex: prevMeta.seriesIndex,
|
||||
work: prevMeta.work,
|
||||
// A new file: its articles are read anew; what was read of them stays.
|
||||
issueRead: prevMeta.issueRead,
|
||||
issueAt: prevMeta.issueAt,
|
||||
|
|
@ -445,6 +446,8 @@ async function _updateBookMeta(bookId, overrides) {
|
|||
cover: cached.cover !== undefined ? cached.cover : book.cover,
|
||||
series: cached.series !== undefined ? cached.series : book.series,
|
||||
seriesIndex: cached.seriesIndex !== undefined ? cached.seriesIndex : book.seriesIndex,
|
||||
// Editions of one work (53-library.js): the same key on each.
|
||||
work: cached.work !== undefined ? cached.work : book.work,
|
||||
// An issue's articles and what was read of them (54-issues.js).
|
||||
issue: cached.issue !== undefined ? cached.issue : book.issue,
|
||||
issueRead: cached.issueRead !== undefined ? cached.issueRead : book.issueRead,
|
||||
|
|
|
|||
|
|
@ -127,6 +127,124 @@ function _libAllSeries(books) {
|
|||
return map;
|
||||
}
|
||||
|
||||
// ---- editions: one work as PDF and EPUB (or two translations …), shown once ----------------------
|
||||
// Books with the same `work` key in their meta are editions of one work. The list shows one of
|
||||
// them: the one read last, else an EPUB, else the first added; the others are behind "Ausgaben".
|
||||
|
||||
const _libWork = b => (b && typeof b.work === 'string' && b.work) || '';
|
||||
|
||||
function _libEditionGroups(books) {
|
||||
const map = new Map();
|
||||
books.forEach(b => {
|
||||
const w = _libWork(b);
|
||||
if (!w) return;
|
||||
if (!map.has(w)) map.set(w, []);
|
||||
map.get(w).push(b);
|
||||
});
|
||||
map.forEach((list, w) => { if (list.length < 2) map.delete(w); });
|
||||
return map;
|
||||
}
|
||||
|
||||
function _libEditionLead(list) {
|
||||
return list.slice().sort((a, b) => (_bookRecency(b) - _bookRecency(a)) ||
|
||||
((a.type === 'epub' ? 0 : 1) - (b.type === 'epub' ? 0 : 1)) || a.id - b.id)[0];
|
||||
}
|
||||
|
||||
// The books with every work once; _lib.editions: the shown book's id -> all its editions.
|
||||
function _libCollapse(books) {
|
||||
const groups = _libEditionGroups(books);
|
||||
_lib.editions = new Map();
|
||||
if (!groups.size) return books;
|
||||
const hide = new Set();
|
||||
groups.forEach(list => {
|
||||
const lead = _libEditionLead(list);
|
||||
_lib.editions.set(lead.id, list);
|
||||
list.forEach(b => { if (b !== lead) hide.add(b.id); });
|
||||
});
|
||||
return books.filter(b => !hide.has(b.id));
|
||||
}
|
||||
|
||||
function _libEditionsOf(b) {
|
||||
const w = _libWork(b);
|
||||
return w ? (_lastBookListData || []).filter(x => _libWork(x) === w) : [b];
|
||||
}
|
||||
|
||||
const _libFormat = b => (b.type || 'epub').toUpperCase();
|
||||
|
||||
function _libFormats(list) {
|
||||
return Array.from(new Set(list.map(_libFormat))).join(' · ');
|
||||
}
|
||||
|
||||
async function _libLinkEditions(ids) {
|
||||
const books = ids.map(id => _lastBookListData.find(b => b.id === id)).filter(Boolean);
|
||||
if (books.length < 2) return;
|
||||
const key = books.map(_libWork).filter(Boolean)[0] ||
|
||||
'w' + Date.now().toString(36) + Math.random().toString(36).slice(2, 8);
|
||||
try {
|
||||
for (const b of books) if (_libWork(b) !== key) await _updateBookMeta(b.id, {work: key});
|
||||
renderBookList(_lastBookListData);
|
||||
} catch (e) { customAlert('Das ließ sich nicht speichern: ' + e.message); }
|
||||
}
|
||||
|
||||
async function _libUnlinkEdition(b) {
|
||||
const rest = _libEditionsOf(b).filter(x => x !== b);
|
||||
try {
|
||||
await _updateBookMeta(b.id, {work: ''});
|
||||
// One left on its own is no longer an edition of anything.
|
||||
if (rest.length === 1) await _updateBookMeta(rest[0].id, {work: ''});
|
||||
renderBookList(_lastBookListData);
|
||||
} catch (e) { customAlert('Das ließ sich nicht speichern: ' + e.message); }
|
||||
}
|
||||
|
||||
// "Ausgaben": the editions of a book, each to open or to set apart; or, for a book with none,
|
||||
// another book to join it with (the ones with a similar title first).
|
||||
function _libEditionsSheet(b) {
|
||||
const eds = _libEditionsOf(b);
|
||||
if (eds.length > 1) {
|
||||
const rows = eds.slice().sort((x, y) => x.id - y.id).map(x => `<div class="lib-ed-row">
|
||||
<button type="button" class="lib-sheet-item" data-open="${x.id}"><b>${_libEsc(_libFormat(x))}</b> · ${_libEsc(_libState(x))}
|
||||
<span class="lib-meta">${_libEsc(x.title)}${x.last_read ? ' · ' + _libEsc(_libAgo(x.last_read)) : ''}</span></button>
|
||||
<button type="button" class="lib-btn" data-unlink="${x.id}">Lösen</button></div>`).join('');
|
||||
const wrap = _libSheetBox(`Ausgaben von „${b.title}“`, `<div class="dfo-list">${rows}</div>
|
||||
<div class="dfo-actions"><button type="button" class="dfo-btn" data-add="1">Weitere Ausgabe verbinden …</button></div>`);
|
||||
wrap.addEventListener('click', e => {
|
||||
const o = e.target.closest('[data-open]'), u = e.target.closest('[data-unlink]'), a = e.target.closest('[data-add]');
|
||||
if (o) { wrap.remove(); openBook(Number(o.dataset.open)); }
|
||||
else if (u) { wrap.remove(); _libUnlinkEdition(eds.find(x => x.id === Number(u.dataset.unlink))); }
|
||||
else if (a) { wrap.remove(); _libPickEdition(b); }
|
||||
});
|
||||
return;
|
||||
}
|
||||
_libPickEdition(b);
|
||||
}
|
||||
|
||||
function _libPickEdition(b) {
|
||||
const mine = new Set(_libEditionsOf(b).map(x => x.id));
|
||||
const words = t => _libNorm(t).split(/[^a-z0-9äöüß]+/).filter(w => w.length > 3);
|
||||
const own = new Set(words(b.title).concat(words(b.author)));
|
||||
const score = x => words(x.title + ' ' + x.author).filter(w => own.has(w)).length;
|
||||
const others = _lastBookListData.filter(x => !mine.has(x.id) && x.keyOk !== false)
|
||||
.map(x => [score(x), x]).sort((p, q) => q[0] - p[0] || p[1].title.localeCompare(q[1].title, 'de')).map(p => p[1]);
|
||||
const item = x => `<button type="button" class="lib-sheet-item" data-pick="${x.id}"><b>${_libEsc(_libFormat(x))}</b> · ${_libEsc(x.title)}
|
||||
<span class="lib-meta">${_libEsc(x.author || '')}</span></button>`;
|
||||
const wrap = _libSheetBox(`Als Ausgabe von „${b.title}“`, `<input class="dfo-input lib-ed-q" type="search" placeholder="Titel, Autor …">
|
||||
<div class="dfo-list lib-ed-list">${others.slice(0, 30).map(item).join('')}</div>`);
|
||||
const q = wrap.querySelector('.lib-ed-q');
|
||||
q.addEventListener('input', () => {
|
||||
const t = _libNorm(q.value).trim();
|
||||
wrap.querySelector('.lib-ed-list').innerHTML = others.filter(x => !t || _libNorm(x.title + ' ' + x.author).indexOf(t) >= 0)
|
||||
.slice(0, 30).map(item).join('');
|
||||
});
|
||||
wrap.addEventListener('click', e => {
|
||||
const p = e.target.closest('[data-pick]');
|
||||
if (!p) return;
|
||||
wrap.remove();
|
||||
_libLinkEditions([b.id, Number(p.dataset.pick)]);
|
||||
});
|
||||
}
|
||||
window.bookEditions = id => { const b = (_lastBookListData || []).find(x => x.id === Number(id)); if (b) _libEditionsSheet(b); };
|
||||
window.bookEditionsOf = id => { const b = (_lastBookListData || []).find(x => x.id === Number(id)); return b ? _libEditionsOf(b) : []; };
|
||||
|
||||
// ---- which books, in which order ---------------------------------------------------------------
|
||||
|
||||
function _libShown(books) {
|
||||
|
|
@ -180,7 +298,10 @@ function _libState(b) {
|
|||
|
||||
function _libCard(b, label) {
|
||||
const sel = _lib.selecting && _lib.selecting.has(b.id);
|
||||
const by = (label ? label + (b.author ? ' · ' : '') : '') + (b.author || ''); // "Band 2 · Autor" in a series
|
||||
const eds = _lib.editions && _lib.editions.get(b.id);
|
||||
const by = (label ? label + (b.author ? ' · ' : '') : '') + (b.author || '') + // "Band 2 · Autor" in a series
|
||||
(eds && _libLayout() === 'list' ? ' · ' + _libFormats(eds) : '');
|
||||
const edBadge = eds ? `<span class="lib-ed" title="${eds.length} Ausgaben">${_libEsc(_libFormats(eds))}</span>` : '';
|
||||
const pct = _libPct(b);
|
||||
// An issue with its articles (54-issues.js): read of them instead of a percentage.
|
||||
const iss = typeof _issueSummary === 'function' && _isMagazine(b) ? _issueSummary(b) : null;
|
||||
|
|
@ -208,7 +329,7 @@ function _libCard(b, label) {
|
|||
<button type="button" class="lib-more" data-lib-more="${b.id}" aria-label="Optionen">⋮</button></div>`;
|
||||
}
|
||||
return `<div class="lib-card${sel ? ' lib-sel' : ''}" data-lib-book="${b.id}">
|
||||
<span class="lib-coverbox">${_libCover(b, 'lib-cover')}
|
||||
<span class="lib-coverbox">${_libCover(b, 'lib-cover')}${edBadge}
|
||||
${b.is_read ? '<span class="lib-badge">✓</span>' : ''}
|
||||
<button type="button" class="lib-more" data-lib-more="${b.id}" aria-label="Optionen">⋮</button></span>
|
||||
<span class="lib-bar"><span style="width:${pct}%"></span></span>
|
||||
|
|
@ -339,7 +460,7 @@ function _libRender(allBooks) {
|
|||
const listEl = $('book-list');
|
||||
if (!listEl) return;
|
||||
_lastBookListData = allBooks;
|
||||
const books = _bookSectionFilter(allBooks);
|
||||
const books = _libCollapse(_bookSectionFilter(allBooks));
|
||||
const magazines = window.DioraNav && _bookSection === 'magazines';
|
||||
const shown = _libShown(books);
|
||||
// An issue opened (54-issues.js): its contents instead of the list, without the list's search and chips.
|
||||
|
|
@ -357,6 +478,7 @@ function _libRender(allBooks) {
|
|||
if (_lib.selecting) {
|
||||
h += `<div class="lib-selbar"><span>${_lib.selecting.size} ausgewählt</span>
|
||||
<button type="button" class="lib-btn" data-lib="sel-folders"${_lib.selecting.size ? '' : ' disabled'}>In Ordner …</button>
|
||||
<button type="button" class="lib-btn" data-lib="sel-editions"${_lib.selecting.size > 1 ? '' : ' disabled'}>Als Ausgaben verbinden</button>
|
||||
<button type="button" class="lib-btn" data-lib="sel-done">Fertig</button></div>`;
|
||||
}
|
||||
if (!allBooks.length) {
|
||||
|
|
@ -571,6 +693,7 @@ function _libSheet(b) {
|
|||
[_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)],
|
||||
['Serie …', () => _libSeriesSheet(b)],
|
||||
[_libEditionsOf(b).length > 1 ? `Ausgaben (${_libEditionsOf(b).length}) …` : 'Als Ausgabe verbinden …', () => _libEditionsSheet(b)],
|
||||
[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)],
|
||||
|
|
@ -624,6 +747,11 @@ function _libSheet(b) {
|
|||
} else if (a === 'add') _libAddMenu();
|
||||
else if (a === 'sel-done') { _lib.selecting = null; renderBookList(_lastBookListData); }
|
||||
else if (a === 'sel-folders') _libAssign(Array.from(_lib.selecting || []));
|
||||
else if (a === 'sel-editions') {
|
||||
const ids = Array.from(_lib.selecting || []);
|
||||
_lib.selecting = null;
|
||||
_libLinkEditions(ids);
|
||||
}
|
||||
else if (a === 'series-up') { _lib.series = null; renderBookList(_lastBookListData); }
|
||||
else if (a === 'series-manage') _libSeriesManage();
|
||||
return;
|
||||
|
|
@ -794,6 +922,13 @@ function _libAddMenu() {
|
|||
'#book-list .lib-thumb.lib-nocover span { display: none; }',
|
||||
'#book-list .lib-thumb.lib-nocover b { font-size: 15px; align-self: center; margin: auto; }',
|
||||
'#book-list .lib-thumb-l { width: 52px; height: 78px; }',
|
||||
'#book-list .lib-ed { position: absolute; left: 4px; top: 4px; padding: 1px 6px; border-radius: 4px; background: rgba(0,0,0,.62); color: #fff; font-size: 10px; letter-spacing: .03em; }',
|
||||
'body.eink #book-list .lib-ed { background: #fff; color: #000; border: 1px solid #000; }',
|
||||
'.lib-ed-row { display: flex; align-items: center; gap: 8px; border-bottom: 1px solid var(--border, #2a2a2a); }',
|
||||
'.lib-ed-row .lib-sheet-item { border-bottom: none; flex: 1; }',
|
||||
'.lib-sheet-item .lib-meta { display: block; font-size: 12px; color: var(--muted, #9a9a9a); }',
|
||||
'.lib-ed-q { width: 100%; box-sizing: border-box; margin: 0 0 8px; }',
|
||||
'.lib-ed-list { max-height: 55vh; overflow-y: auto; }',
|
||||
'#book-list .lib-badge { position: absolute; left: 6px; bottom: 6px; padding: 1px 6px; border-radius: 9px; background: var(--accent, #e63946); color: var(--on-accent, #fff); font-size: 11px; }',
|
||||
'#book-list .lib-card .lib-more { position: absolute; right: 2px; top: 2px; width: 30px; height: 30px; border-radius: 15px; border: none; background: rgba(0,0,0,.45); color: #fff; font-size: 16px; cursor: pointer; opacity: 0; }',
|
||||
'#book-list .lib-card:hover .lib-more, #book-list .lib-card:focus-within .lib-more { opacity: 1; }',
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
* diora service worker — caches the app shell for offline use.
|
||||
*/
|
||||
|
||||
const CACHE = 'diora-v130';
|
||||
const CACHE = 'diora-v131';
|
||||
const SHELL = [
|
||||
'/static/css/app.css',
|
||||
'/static/js/app/01-core.js',
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue