novela/containers/novela/static/library.js
Ivo Oskamp b43366723c Add Bulk Import, Following, Incomplete, status overhaul, performance, and CBR fixes
- Bulk Import page: filename pattern parsing, shared metadata, duplicate detection (volume-aware), batch upload with progress
- Following page: track external author URLs; authors table; sidebar counter
- Incomplete view: non-archived books with publication_status ≠ Complete
- Status: added Temporary Hold, renamed Hiatus → Long-Term Hold; statusBadgeHtml() helper
- Status/want-to-read badges: dark fill + ring for readability on any cover colour
- Disk usage warning in sidebar (amber/red thresholds)
- Bulk delete batched via POST /library/bulk-delete
- CBR: magic bytes format detection + py7zr 7-zip support; unrar → proprietary unrar v6
- Performance: IntersectionObserver lazy covers, ETag 304, single DOM pass, json_agg tags
- Duplicate detection in library and Convert page warning
- All books Grid/List toggle; star ratings; reader text colour presets; bookmarks
- Docs: TECHNICAL.md and changelog updated

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-29 14:20:25 +02:00

1939 lines
75 KiB
JavaScript

/* ── Novela — Library page script ─────────────────────────────────────── */
function statusBadgeHtml(publicationStatus) {
const st = (publicationStatus || '').toLowerCase();
if (st === 'complete') {
return `<div class="badge-status badge-complete" title="Complete"><svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3"><polyline points="20 6 9 17 4 12"/></svg></div>`;
} else if (st === 'ongoing') {
return `<div class="badge-status badge-ongoing" title="Ongoing"><svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg></div>`;
} else if (st === 'temporary hold') {
return `<div class="badge-status badge-temporary-hold" title="Temporary Hold"><svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3"><line x1="10" y1="9" x2="10" y2="15"/><line x1="14" y1="9" x2="14" y2="15"/><circle cx="12" cy="12" r="10"/></svg></div>`;
} else if (st === 'long-term hold') {
return `<div class="badge-status badge-long-term-hold" title="Long-Term Hold"><svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3"><path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/></svg></div>`;
}
return '';
}
let allBooks = [];
let currentView = 'all';
let currentParam = null;
let pendingDelete = null;
let coverTargetFilename = null;
let coverB64 = null;
let importInProgress = false;
const MISSING_PUBLISHER_KEY = '__missing__';
const MISSING_PUBLISHER_LABEL = 'No publisher';
const IMPORT_EXTENSIONS = ['.epub', '.pdf', '.cbr', '.cbz'];
const NEW_VIEW_MODE_KEY = 'novela.new.viewMode';
const NEW_VISIBLE_COLUMNS_KEY = 'novela.new.visibleColumns';
const ALL_VIEW_MODE_KEY = 'novela.all.viewMode';
const ALL_VISIBLE_COLUMNS_KEY = 'novela.all.visibleColumns';
const NEW_DEFAULT_COLUMNS = ['publisher', 'author', 'series', 'volume', 'title', 'has_cover', 'updated', 'genres', 'subgenres', 'tags', 'status'];
const NEW_COLUMN_DEFS = [
{ id: 'publisher', label: 'Publisher' },
{ id: 'author', label: 'Author' },
{ id: 'series', label: 'Series' },
{ id: 'volume', label: 'Volume' },
{ id: 'title', label: 'Title' },
{ id: 'has_cover', label: 'Has cover' },
{ id: 'updated', label: 'Updated' },
{ id: 'genres', label: 'Genres' },
{ id: 'subgenres', label: 'Sub-genres' },
{ id: 'tags', label: 'Tags' },
{ id: 'status', label: 'Status' },
{ id: 'rating', label: 'Rating' },
];
let newViewMode = loadNewViewMode();
let newVisibleColumns = loadNewVisibleColumns();
let newSelectedFilenames = new Set();
let newLastToggledIndex = null;
let allViewMode = loadAllViewMode();
let allVisibleColumns = loadAllVisibleColumns();
let allSelectedFilenames = new Set();
let allLastToggledIndex = null;
// ── Placeholder cover generation ───────────────────────────────────────────
function strHash(s) {
let h = 0;
for (let i = 0; i < s.length; i++) h = (Math.imul(31, h) + s.charCodeAt(i)) | 0;
return Math.abs(h);
}
const COVER_PALETTES = [
['#1a2a3a', '#4a8caa'],
['#2a1a1a', '#aa4a4a'],
['#1a2a1a', '#4aaa6a'],
['#2a1a2a', '#8a4aaa'],
['#2a2a1a', '#aaa04a'],
['#1a2a2a', '#4aaa9a'],
['#2a1a14', '#c8783a'],
['#141a2a', '#5a78c8'],
];
function makePlaceholderCover(canvas, title, author) {
const w = canvas.width = canvas.offsetWidth || 150;
const h = canvas.height = canvas.offsetHeight || 225;
const ctx = canvas.getContext('2d');
const [bg, fg] = COVER_PALETTES[strHash(title) % COVER_PALETTES.length];
ctx.fillStyle = bg;
ctx.fillRect(0, 0, w, h);
ctx.fillStyle = fg;
ctx.globalAlpha = 0.15;
ctx.fillRect(0, 0, w, h * 0.08);
ctx.globalAlpha = 1;
ctx.fillStyle = fg;
ctx.fillRect(w * 0.12, h * 0.12, w * 0.04, h * 0.55);
ctx.fillStyle = '#e8e2d9';
ctx.font = `bold ${Math.round(w * 0.105)}px 'Libre Baskerville', Georgia, serif`;
ctx.textAlign = 'center';
wrapText(ctx, title, w * 0.55, h * 0.28, w * 0.72, Math.round(w * 0.12));
ctx.fillStyle = fg;
ctx.font = `${Math.round(w * 0.075)}px 'DM Mono', monospace`;
ctx.globalAlpha = 0.85;
ctx.fillText(truncate(author, 18), w * 0.55, h * 0.86);
ctx.globalAlpha = 1;
}
function wrapText(ctx, text, x, y, maxW, lineH) {
const words = text.split(' ');
let line = '';
let lines = [];
for (const word of words) {
const test = line ? line + ' ' + word : word;
if (ctx.measureText(test).width > maxW && line) { lines.push(line); line = word; }
else line = test;
}
if (line) lines.push(line);
lines = lines.slice(0, 4);
const startY = y - ((lines.length - 1) * lineH) / 2;
lines.forEach((l, i) => ctx.fillText(l, x, startY + i * lineH));
}
function truncate(s, n) { return s.length > n ? s.slice(0, n - 1) + '…' : s; }
// ── Data loading ───────────────────────────────────────────────────────────
let _libraryETag = null;
async function loadLibrary() {
try {
const headers = {};
if (_libraryETag) headers['If-None-Match'] = _libraryETag;
const resp = await fetch('/library/list', { headers });
if (resp.status === 304) {
// Data unchanged — skip JSON parse, just re-render current view
updateCounts();
renderGrid();
return true;
}
if (!resp.ok) {
document.getElementById('grid-container').innerHTML =
`<div class="empty">Failed to load library (HTTP ${resp.status}). Check server logs.</div>`;
return false;
}
const etag = resp.headers.get('ETag');
if (etag) _libraryETag = etag;
allBooks = await resp.json();
updateCounts();
renderGrid();
return true;
} catch (err) {
console.error('loadLibrary error:', err);
document.getElementById('grid-container').innerHTML =
`<div class="empty">Failed to load library: ${String(err)}. Check browser console.</div>`;
return false;
}
}
function activeBooks() { return allBooks.filter(b => !b.archived); }
function archivedBooks() { return allBooks.filter(b => b.archived); }
function updateCounts() {
const active = activeBooks();
const wtrCount = active.filter(b => b.want_to_read).length;
const seriesCount = new Set(active.filter(b => b.series).map(b => b.series)).size;
const authorCount = new Set(active.map(b => bookAuthor(b)).filter(Boolean)).size;
const publisherCount = new Set(active.map(b => bookPublisherKey(b))).size;
const newCount = active.filter(b => b.needs_review).length;
const archCount = archivedBooks().length;
document.getElementById('count-all').textContent = active.length || '';
document.getElementById('count-wtr').textContent = wtrCount || '';
document.getElementById('count-series').textContent = seriesCount || '';
document.getElementById('count-authors').textContent = authorCount || '';
document.getElementById('count-publishers').textContent = publisherCount || '';
const newEl = document.getElementById('count-new');
if (newEl) newEl.textContent = newCount || '';
const archEl = document.getElementById('count-archived');
if (archEl) archEl.textContent = archCount || '';
const ratedCount = active.filter(b => b.rating > 0).length;
const ratedEl = document.getElementById('count-rated');
if (ratedEl) ratedEl.textContent = ratedCount || '';
const dupGroups = _duplicateGroups(active);
const dupCount = dupGroups.reduce((s, g) => s + g.books.length, 0);
const dupEl = document.getElementById('count-duplicates');
if (dupEl) dupEl.textContent = dupCount || '';
}
function _filenameBase(filename) {
const leaf = String(filename || '').split('/').pop() || '';
return leaf.replace(/\.[^.]+$/, '');
}
function bookAuthor(b) {
if (b.author) return b.author;
const parts = _filenameBase(b.filename).split('-');
return (parts[1] ?? '').replace(/_/g, ' ');
}
function bookTitle(b) {
return b.title || (_filenameBase(b.filename).split('-')[2] ?? '').replace(/_/g, ' ');
}
function normalizePublisherName(value) {
const v = (value || '').trim();
if (!v) return MISSING_PUBLISHER_KEY;
const low = v.toLowerCase();
if (low === 'unknown publisher') return MISSING_PUBLISHER_KEY;
return v;
}
function publisherDisplayName(key) {
return key === MISSING_PUBLISHER_KEY ? MISSING_PUBLISHER_LABEL : key;
}
function bookPublisherKey(b) {
return normalizePublisherName(b.publisher);
}
// ── View switching ─────────────────────────────────────────────────────────
function _viewUrl(view, param) {
if (view === 'wtr') return '/library#wtr';
if (view === 'series') return '/library#series';
if (view === 'series-detail') return '/library#series/' + encodeURIComponent(param || '');
if (view === 'authors') return '/library#authors';
if (view === 'author-detail') return '/library#authors/' + encodeURIComponent(param || '');
if (view === 'publishers') return '/library#publishers';
if (view === 'publisher-detail') return '/library#publishers/' + encodeURIComponent(param || '');
if (view === 'archived') return '/library#archived';
if (view === 'bookmarks') return '/library#bookmarks';
if (view === 'rated') return '/library#rated';
if (view === 'duplicates') return '/library#duplicates';
if (view === 'incomplete') return '/library#incomplete';
if (view === 'new') return '/library#new';
if (view === 'genre') return '/library#genre/' + encodeURIComponent(param || '');
return '/library';
}
function _applyView(view, param) {
currentView = view;
currentParam = param || null;
// Clear search input when switching to a non-search view
if (view !== 'search') {
const si = document.getElementById('search-input');
if (si) { si.value = ''; document.getElementById('search-clear').style.display = 'none'; }
}
['nav-all','nav-wtr','nav-new','nav-incomplete','nav-series','nav-authors','nav-publishers','nav-archived','nav-bookmarks','nav-rated','nav-duplicates'].forEach(id => {
const el = document.getElementById(id);
if (el) el.classList.remove('active');
});
const activeMap = {
'all': 'nav-all', 'wtr': 'nav-wtr',
'series': 'nav-series', 'series-detail': 'nav-series',
'authors': 'nav-authors', 'author-detail': 'nav-authors',
'publishers': 'nav-publishers', 'publisher-detail': 'nav-publishers',
'new': 'nav-new',
'incomplete': 'nav-incomplete',
'archived': 'nav-archived',
'bookmarks': 'nav-bookmarks',
'rated': 'nav-rated',
'duplicates': 'nav-duplicates',
};
const el = document.getElementById(activeMap[view]);
if (el) el.classList.add('active');
document.getElementById('section-title').textContent =
view === 'all' ? 'All books' :
view === 'wtr' ? 'Want to Read' :
view === 'series' ? 'Series' :
view === 'series-detail' ? (param || '') :
view === 'authors' ? 'Authors' :
view === 'author-detail' ? (param || '') :
view === 'publishers' ? 'Publishers' :
view === 'publisher-detail' ? publisherDisplayName(param || '') :
view === 'new' ? 'New' :
view === 'archived' ? 'Archived' :
view === 'bookmarks' ? 'Bookmarks' :
view === 'rated' ? 'Rated' :
view === 'duplicates' ? 'Duplicates' :
view === 'incomplete' ? 'Incomplete' :
view === 'genre' ? `Genre: ${param || ''}` :
view === 'search' ? `Search: "${param || ''}"` : '';
if (view !== 'new') {
newSelectedFilenames.clear();
newLastToggledIndex = null;
}
if (view !== 'all') {
allSelectedFilenames.clear();
allLastToggledIndex = null;
}
const showBack = view === 'series-detail' || view === 'author-detail' || view === 'publisher-detail';
document.getElementById('back-btn').style.display = showBack ? '' : 'none';
renderGrid();
}
function switchView(view, param) {
history.pushState({ view, param: param || null }, '', _viewUrl(view, param));
_applyView(view, param);
}
function goBack() { history.back(); }
window.addEventListener('popstate', e => {
if (e.state) _applyView(e.state.view, e.state.param);
else _applyView('all', null);
});
// ── Render dispatcher ──────────────────────────────────────────────────────
function renderGrid() {
const active = activeBooks();
if (currentView !== 'new') hideNewControls();
if (currentView !== 'all') hideAllControls();
if (currentView === 'all') renderAllView(active);
else if (currentView === 'wtr') renderBooksGrid(active.filter(b => b.want_to_read));
else if (currentView === 'series') renderSeriesGrid();
else if (currentView === 'series-detail') renderSeriesDetail(currentParam);
else if (currentView === 'authors') renderAuthorsView();
else if (currentView === 'author-detail') renderAuthorDetail(currentParam);
else if (currentView === 'publishers') renderPublishersView();
else if (currentView === 'publisher-detail') renderPublisherDetail(currentParam);
else if (currentView === 'archived') renderBooksGrid(archivedBooks());
else if (currentView === 'new') renderNewBooksView(active.filter(b => b.needs_review));
else if (currentView === 'genre') renderGenreView(currentParam);
else if (currentView === 'search') renderSearchResults(currentParam);
else if (currentView === 'bookmarks') renderBookmarksView();
else if (currentView === 'rated') renderRatedView();
else if (currentView === 'duplicates') renderDuplicatesView();
else if (currentView === 'incomplete') renderBooksGrid(active.filter(b => (b.publication_status || '').toLowerCase() !== 'complete'));
}
// ── New view (bulk review + list/grid toggle) ─────────────────────────────
function loadNewViewMode() {
try {
const raw = localStorage.getItem(NEW_VIEW_MODE_KEY);
return raw === 'list' ? 'list' : 'grid';
} catch {
return 'grid';
}
}
function loadNewVisibleColumns() {
try {
const raw = localStorage.getItem(NEW_VISIBLE_COLUMNS_KEY);
if (!raw) return [...NEW_DEFAULT_COLUMNS];
const parsed = JSON.parse(raw);
if (!Array.isArray(parsed)) return [...NEW_DEFAULT_COLUMNS];
const allowed = new Set(NEW_COLUMN_DEFS.map(c => c.id));
const saved = new Set(parsed.filter(v => typeof v === 'string' && allowed.has(v)));
const normalized = NEW_COLUMN_DEFS.map(c => c.id).filter(id => saved.has(id));
if (!normalized.length) return [...NEW_DEFAULT_COLUMNS];
return normalized;
} catch {
return [...NEW_DEFAULT_COLUMNS];
}
}
function persistNewColumns() {
try {
localStorage.setItem(NEW_VISIBLE_COLUMNS_KEY, JSON.stringify(newVisibleColumns));
} catch {
// ignore storage failures
}
}
function persistNewViewMode() {
try {
localStorage.setItem(NEW_VIEW_MODE_KEY, newViewMode);
} catch {
// ignore storage failures
}
}
function hideNewControls() {
const controls = document.getElementById('new-controls');
if (!controls) return;
controls.style.display = 'none';
controls.innerHTML = '';
}
function setNewViewMode(mode) {
if (mode !== 'grid' && mode !== 'list') return;
newViewMode = mode;
if (mode === 'grid') {
newSelectedFilenames.clear();
newLastToggledIndex = null;
}
persistNewViewMode();
renderGrid();
}
function toggleNewColumnsMenu(ev) {
ev?.stopPropagation();
const menu = document.getElementById('new-columns-menu');
if (!menu) return;
menu.classList.toggle('visible');
}
function toggleNewColumn(columnId) {
const set = new Set(newVisibleColumns);
if (set.has(columnId)) set.delete(columnId);
else set.add(columnId);
const ordered = NEW_COLUMN_DEFS.map(c => c.id).filter(id => set.has(id));
newVisibleColumns = ordered.length ? ordered : ['title'];
persistNewColumns();
renderGrid();
}
function toggleSelectAllNewRows(checked, books) {
if (checked) {
books.forEach(b => newSelectedFilenames.add(b.filename));
newLastToggledIndex = books.length ? books.length - 1 : null;
} else {
books.forEach(b => newSelectedFilenames.delete(b.filename));
newLastToggledIndex = null;
}
renderNewControls(books);
if (newViewMode === 'list') {
const rowChecks = document.querySelectorAll('.new-row-select');
rowChecks.forEach(cb => { cb.checked = checked; });
}
}
function toggleNewRowWithShift(filename, checked, shiftPressed) {
const books = activeBooks().filter(b => b.needs_review);
const filenames = books.map(b => b.filename);
const idx = filenames.indexOf(filename);
if (idx === -1) return;
const doRange = !!(shiftPressed && newLastToggledIndex !== null);
if (doRange) {
const start = Math.min(newLastToggledIndex, idx);
const end = Math.max(newLastToggledIndex, idx);
for (let i = start; i <= end; i++) {
const name = filenames[i];
if (checked) newSelectedFilenames.add(name);
else newSelectedFilenames.delete(name);
}
} else {
if (checked) newSelectedFilenames.add(filename);
else newSelectedFilenames.delete(filename);
}
newLastToggledIndex = idx;
renderNewControls(books);
renderNewBooksList(books);
}
function handleNewRowCheckboxClick(filename, checkboxEl, ev) {
ev?.stopPropagation();
const shiftPressed = !!(ev && ev.shiftKey);
toggleNewRowWithShift(filename, !!checkboxEl?.checked, shiftPressed);
}
function clearNewSelection(books) {
books.forEach(b => newSelectedFilenames.delete(b.filename));
newLastToggledIndex = null;
renderGrid();
}
async function markSelectedNewAsReviewed(books) {
const selected = books.filter(b => newSelectedFilenames.has(b.filename)).map(b => b.filename);
if (!selected.length) return;
const btn = document.getElementById('btn-mark-reviewed');
if (btn) btn.disabled = true;
let succeeded = false;
try {
const resp = await fetch('/library/new/mark-reviewed', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ filenames: selected }),
});
const result = await resp.json();
if (!resp.ok || result.error) {
alert(result.error || 'Could not mark books as reviewed.');
} else {
succeeded = true;
}
} catch {
alert('Could not mark books as reviewed.');
} finally {
if (btn) btn.disabled = false;
}
if (succeeded) {
selected.forEach(f => newSelectedFilenames.delete(f));
await loadLibrary();
}
}
function tagValuesByType(book, type) {
return (book.tags || [])
.filter(t => t && t.tag_type === type && t.tag)
.map(t => t.tag);
}
function bookGenres(book) {
const explicit = tagValuesByType(book, 'genre');
if (explicit.length) return explicit;
return (book.tags || [])
.filter(t => t && t.tag_type === 'subject' && t.tag)
.map(t => t.tag);
}
function bookSubgenres(book) {
return tagValuesByType(book, 'subgenre');
}
function bookPlainTags(book) {
return tagValuesByType(book, 'tag');
}
function formatUpdated(iso) {
if (!iso) return '';
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return '';
const y = d.getFullYear();
const m = String(d.getMonth() + 1).padStart(2, '0');
const day = String(d.getDate()).padStart(2, '0');
return `${y}-${m}-${day}`;
}
function starsText(rating) {
const r = rating || 0;
return '★'.repeat(r) + '☆'.repeat(5 - r);
}
function starsHtml(filename, rating, interactive = false) {
const r = rating || 0;
const id = cssId(filename);
const cls = interactive ? 'star-row interactive' : 'star-row';
let html = `<div class="${cls}" id="stars-${id}">`;
for (let i = 1; i <= 5; i++) {
const onclick = interactive ? ` onclick="event.stopPropagation();rateBook('${jsEsc(filename)}',${i})"` : '';
html += `<span class="star ${i <= r ? 'filled' : ''}"${onclick}>★</span>`;
}
html += '</div>';
return html;
}
async function rateBook(filename, rating) {
const book = allBooks.find(b => b.filename === filename);
const newRating = (book && book.rating === rating) ? 0 : rating;
try {
const resp = await fetch(`/library/rating/${encodeURIComponent(filename)}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ rating: newRating }),
});
const result = await resp.json();
if (!resp.ok || result.error) return;
if (book) book.rating = result.rating;
const id = cssId(filename);
const row = document.getElementById(`stars-${id}`);
if (row) {
row.outerHTML = starsHtml(filename, result.rating);
}
} catch {}
}
// Returns the set of series names where at least one book has series_index > 0.
// Used to decide whether to show [0] labels for index-0 books in indexed series.
function indexedSeriesSet() {
const set = new Set();
for (const b of allBooks) {
if (b.series && b.series_index > 0) set.add(b.series);
}
return set;
}
function seriesVolLabel(book, indexedSeries) {
if (book.series_index > 0 || book.series_suffix) return String(book.series_index) + (book.series_suffix || '');
if (book.series && indexedSeries.has(book.series)) return '0';
return '';
}
function newCellText(book, colId) {
if (colId === 'publisher') return publisherDisplayName(bookPublisherKey(book));
if (colId === 'author') return bookAuthor(book);
if (colId === 'series') return book.series || '';
if (colId === 'title') return bookTitle(book);
if (colId === 'has_cover') return book.has_cover ? 'Yes' : 'No';
if (colId === 'updated') return formatUpdated(book.updated_at);
if (colId === 'genres') return bookGenres(book).join(', ');
if (colId === 'subgenres') return bookSubgenres(book).join(', ');
if (colId === 'tags') return bookPlainTags(book).join(', ');
if (colId === 'volume') return seriesVolLabel(book, indexedSeriesSet());
if (colId === 'status') return book.publication_status || '';
if (colId === 'rating') return starsText(book.rating);
return '';
}
function renderNewControls(books) {
const controls = document.getElementById('new-controls');
if (!controls) return;
if (currentView !== 'new') {
hideNewControls();
return;
}
const validFilenames = new Set(books.map(b => b.filename));
newSelectedFilenames.forEach(filename => {
if (!validFilenames.has(filename)) newSelectedFilenames.delete(filename);
});
const listMode = newViewMode === 'list';
const selectedCount = listMode
? books.filter(b => newSelectedFilenames.has(b.filename)).length
: 0;
const allSelected = listMode && !!books.length && selectedCount === books.length;
controls.style.display = '';
controls.innerHTML = `
<div class="new-controls-bar">
<div class="new-view-toggle">
<button class="btn btn-view ${newViewMode === 'grid' ? 'active' : ''}" onclick="setNewViewMode('grid')">Grid</button>
<button class="btn btn-view ${newViewMode === 'list' ? 'active' : ''}" onclick="setNewViewMode('list')">List</button>
</div>
<div class="new-actions">
${listMode ? `
<button class="btn btn-light" onclick="toggleNewColumnsMenu(event)">Columns</button>
<div class="new-columns-menu" id="new-columns-menu">
${NEW_COLUMN_DEFS.map(col => `
<label class="new-col-item">
<input type="checkbox" ${newVisibleColumns.includes(col.id) ? 'checked' : ''} onchange="toggleNewColumn('${col.id}')"/>
<span>${esc(col.label)}</span>
</label>
`).join('')}
</div>
<span class="new-selection-count">${selectedCount} selected</span>
<button class="btn btn-light" onclick="toggleSelectAllNewRows(${allSelected ? 'false' : 'true'}, activeBooks().filter(b => b.needs_review))">${allSelected ? 'Clear all' : 'Select all'}</button>
<button class="btn btn-light" onclick="clearNewSelection(activeBooks().filter(b => b.needs_review))">Clear selection</button>
<button class="btn btn-mark-reviewed" id="btn-mark-reviewed" onclick="markSelectedNewAsReviewed(activeBooks().filter(b => b.needs_review))" ${selectedCount ? '' : 'disabled'}>
Remove from New
</button>
` : `
<span class="new-selection-count">Switch to List to select multiple books</span>
`}
</div>
</div>`;
}
function renderNewBooksList(books) {
const container = document.getElementById('grid-container');
if (!books.length) {
container.innerHTML = '<div class="empty">No newly imported books waiting for metadata review.</div>';
return;
}
const cols = NEW_COLUMN_DEFS.filter(c => newVisibleColumns.includes(c.id));
const selectedCount = books.filter(b => newSelectedFilenames.has(b.filename)).length;
const allSelected = selectedCount === books.length;
container.innerHTML = `
<div class="new-list-wrap">
<table class="new-list-table">
<thead>
<tr>
<th class="new-col-select"><input type="checkbox" ${allSelected ? 'checked' : ''} onchange="toggleSelectAllNewRows(this.checked, activeBooks().filter(b => b.needs_review))"/></th>
${cols.map(c => `<th>${esc(c.label)}</th>`).join('')}
</tr>
</thead>
<tbody>
${books.map(b => `
<tr class="new-list-row" data-filename="${esc(b.filename)}">
<td class="new-col-select"><input class="new-row-select" type="checkbox" ${newSelectedFilenames.has(b.filename) ? 'checked' : ''} onclick="handleNewRowCheckboxClick('${jsEsc(b.filename)}', this, event)"/></td>
${cols.map(c => {
const value = newCellText(b, c.id);
if (c.id === 'title') return `<td class="col-title">${esc(value)}</td>`;
if (c.id === 'has_cover') return `<td class="col-center">${esc(value)}</td>`;
return `<td>${esc(value)}</td>`;
}).join('')}
</tr>
`).join('')}
</tbody>
</table>
</div>`;
container.querySelectorAll('.new-list-row').forEach(row => {
row.addEventListener('click', () => {
const filename = row.getAttribute('data-filename') || '';
if (!filename) return;
location.href = `/library/book/${encodeURIComponent(filename)}`;
});
});
}
function renderNewBooksView(books) {
renderNewControls(books);
if (newViewMode === 'list') {
renderNewBooksList(books);
return;
}
renderBooksGrid(books);
}
// ── All books view (grid/list toggle) ─────────────────────────────────────
function loadAllViewMode() {
try {
const raw = localStorage.getItem(ALL_VIEW_MODE_KEY);
return raw === 'list' ? 'list' : 'grid';
} catch {
return 'grid';
}
}
function loadAllVisibleColumns() {
try {
const raw = localStorage.getItem(ALL_VISIBLE_COLUMNS_KEY);
if (!raw) return [...NEW_DEFAULT_COLUMNS];
const parsed = JSON.parse(raw);
if (!Array.isArray(parsed)) return [...NEW_DEFAULT_COLUMNS];
const allowed = new Set(NEW_COLUMN_DEFS.map(c => c.id));
const saved = new Set(parsed.filter(v => typeof v === 'string' && allowed.has(v)));
const normalized = NEW_COLUMN_DEFS.map(c => c.id).filter(id => saved.has(id));
if (!normalized.length) return [...NEW_DEFAULT_COLUMNS];
return normalized;
} catch {
return [...NEW_DEFAULT_COLUMNS];
}
}
function persistAllViewMode() {
try { localStorage.setItem(ALL_VIEW_MODE_KEY, allViewMode); } catch {}
}
function persistAllVisibleColumns() {
try { localStorage.setItem(ALL_VISIBLE_COLUMNS_KEY, JSON.stringify(allVisibleColumns)); } catch {}
}
function hideAllControls() {
const controls = document.getElementById('all-controls');
if (!controls) return;
controls.style.display = 'none';
controls.innerHTML = '';
}
function setAllViewMode(mode) {
if (mode !== 'grid' && mode !== 'list') return;
allViewMode = mode;
if (mode === 'grid') {
allSelectedFilenames.clear();
allLastToggledIndex = null;
}
persistAllViewMode();
renderGrid();
}
function toggleAllColumnsMenu(ev) {
ev?.stopPropagation();
const menu = document.getElementById('all-columns-menu');
if (!menu) return;
menu.classList.toggle('visible');
}
function toggleAllColumn(columnId) {
const set = new Set(allVisibleColumns);
if (set.has(columnId)) set.delete(columnId);
else set.add(columnId);
const ordered = NEW_COLUMN_DEFS.map(c => c.id).filter(id => set.has(id));
allVisibleColumns = ordered.length ? ordered : ['title'];
persistAllVisibleColumns();
renderGrid();
}
function renderAllControls() {
const controls = document.getElementById('all-controls');
if (!controls) return;
const books = activeBooks();
const listMode = allViewMode === 'list';
const selectedCount = listMode
? books.filter(b => allSelectedFilenames.has(b.filename)).length
: 0;
const allSelected = listMode && !!books.length && selectedCount === books.length;
controls.style.display = '';
controls.innerHTML = `
<div class="new-controls-bar">
<div class="new-view-toggle">
<button class="btn btn-view ${allViewMode === 'grid' ? 'active' : ''}" onclick="setAllViewMode('grid')">Grid</button>
<button class="btn btn-view ${allViewMode === 'list' ? 'active' : ''}" onclick="setAllViewMode('list')">List</button>
</div>
${listMode ? `
<div class="new-actions">
<button class="btn btn-light" onclick="toggleAllColumnsMenu(event)">Columns</button>
<div class="new-columns-menu" id="all-columns-menu">
${NEW_COLUMN_DEFS.map(col => `
<label class="new-col-item">
<input type="checkbox" ${allVisibleColumns.includes(col.id) ? 'checked' : ''} onchange="toggleAllColumn('${col.id}')"/>
<span>${esc(col.label)}</span>
</label>
`).join('')}
</div>
<span class="new-selection-count">${selectedCount} selected</span>
<button class="btn btn-light" onclick="toggleSelectAllAllRows(${allSelected ? 'false' : 'true'}, activeBooks())">${allSelected ? 'Clear all' : 'Select all'}</button>
<button class="btn btn-light" onclick="clearAllSelection()">Clear selection</button>
<button class="btn btn-bulk-delete" id="btn-bulk-delete" onclick="deleteSelectedBooks()" ${selectedCount ? '' : 'disabled'}>
Delete selected
</button>
</div>
` : ''}
</div>`;
}
function renderAllBooksList(books) {
const container = document.getElementById('grid-container');
if (!books.length) {
container.innerHTML = '<div class="empty">No books yet. Import EPUB, PDF or CBR/CBZ to get started.</div>';
return;
}
const cols = NEW_COLUMN_DEFS.filter(c => allVisibleColumns.includes(c.id));
const selectedCount = books.filter(b => allSelectedFilenames.has(b.filename)).length;
const allSelected = !!books.length && selectedCount === books.length;
container.innerHTML = `
<div class="new-list-wrap">
<table class="new-list-table">
<thead>
<tr>
<th class="new-col-select"><input type="checkbox" ${allSelected ? 'checked' : ''} onchange="toggleSelectAllAllRows(this.checked, activeBooks())"/></th>
${cols.map(c => `<th>${esc(c.label)}</th>`).join('')}
</tr>
</thead>
<tbody>
${books.map(b => `
<tr class="all-list-row" data-filename="${esc(b.filename)}">
<td class="new-col-select"><input class="all-row-select" type="checkbox" ${allSelectedFilenames.has(b.filename) ? 'checked' : ''} onclick="handleAllRowCheckboxClick('${jsEsc(b.filename)}', this, event)"/></td>
${cols.map(c => {
const value = newCellText(b, c.id);
if (c.id === 'title') return `<td class="col-title">${esc(value)}</td>`;
if (c.id === 'has_cover') return `<td class="col-center">${esc(value)}</td>`;
return `<td>${esc(value)}</td>`;
}).join('')}
</tr>
`).join('')}
</tbody>
</table>
</div>`;
container.querySelectorAll('.all-list-row').forEach(row => {
row.addEventListener('click', e => {
if (e.target.type === 'checkbox') return;
const filename = row.getAttribute('data-filename') || '';
if (!filename) return;
location.href = `/library/book/${encodeURIComponent(filename)}`;
});
});
}
function renderAllView(books) {
renderAllControls();
if (allViewMode === 'list') {
renderAllBooksList(books);
} else {
renderBooksGrid(books);
}
}
function toggleSelectAllAllRows(checked, books) {
if (checked) {
books.forEach(b => allSelectedFilenames.add(b.filename));
allLastToggledIndex = books.length ? books.length - 1 : null;
} else {
books.forEach(b => allSelectedFilenames.delete(b.filename));
allLastToggledIndex = null;
}
renderAllControls();
const rowChecks = document.querySelectorAll('.all-row-select');
rowChecks.forEach(cb => { cb.checked = checked; });
}
function toggleAllRowWithShift(filename, checked, shiftPressed) {
const books = activeBooks();
const filenames = books.map(b => b.filename);
const idx = filenames.indexOf(filename);
if (idx === -1) return;
const doRange = !!(shiftPressed && allLastToggledIndex !== null);
if (doRange) {
const start = Math.min(allLastToggledIndex, idx);
const end = Math.max(allLastToggledIndex, idx);
for (let i = start; i <= end; i++) {
const name = filenames[i];
if (checked) allSelectedFilenames.add(name);
else allSelectedFilenames.delete(name);
}
} else {
if (checked) allSelectedFilenames.add(filename);
else allSelectedFilenames.delete(filename);
}
allLastToggledIndex = idx;
renderAllControls();
renderAllBooksList(books);
}
function handleAllRowCheckboxClick(filename, checkboxEl, ev) {
ev?.stopPropagation();
toggleAllRowWithShift(filename, !!checkboxEl?.checked, !!(ev && ev.shiftKey));
}
function clearAllSelection() {
activeBooks().forEach(b => allSelectedFilenames.delete(b.filename));
allLastToggledIndex = null;
renderGrid();
}
function deleteSelectedBooks() {
const count = allSelectedFilenames.size;
if (!count) return;
document.getElementById('bulk-delete-count').textContent = count;
document.getElementById('bulk-delete-overlay').classList.add('visible');
}
function closeBulkDeleteDialog() {
document.getElementById('bulk-delete-overlay').classList.remove('visible');
}
async function confirmBulkDelete() {
const filenames = [...allSelectedFilenames];
if (!filenames.length) return;
const btn = document.getElementById('bulk-delete-btn');
const actions = document.getElementById('bulk-delete-actions');
const progress = document.getElementById('bulk-delete-progress');
const bar = document.getElementById('bulk-delete-bar');
const status = document.getElementById('bulk-delete-status');
if (btn) btn.disabled = true;
if (actions) actions.querySelector('.btn-cancel').disabled = true;
if (progress) progress.style.display = 'block';
const BATCH = 20;
const total = filenames.length;
let done = 0;
for (let i = 0; i < filenames.length; i += BATCH) {
const batch = filenames.slice(i, i + BATCH);
try {
await fetch('/library/bulk-delete', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ filenames: batch }),
});
} catch {}
done = Math.min(i + BATCH, total);
if (bar) bar.style.width = Math.round((done / total) * 100) + '%';
if (status) status.textContent = `${done} / ${total} deleted…`;
}
closeBulkDeleteDialog();
if (progress) progress.style.display = 'none';
if (bar) bar.style.width = '0%';
allSelectedFilenames.clear();
allLastToggledIndex = null;
await loadLibrary();
}
// ── Book grid (All / WTR / Author detail) ─────────────────────────────────
let _coverObserver = null;
function renderBooksGrid(books) {
const container = document.getElementById('grid-container');
const idxSeries = indexedSeriesSet();
if (!books.length) {
container.innerHTML = `<div class="empty">${
currentView === 'wtr' ? 'No books marked as Want to Read. Star a book to add it here.' :
currentView === 'archived' ? 'No archived books. Archive a book from its detail page.' :
currentView === 'new' ? 'No newly imported books waiting for metadata review.' :
currentView === 'rated' ? 'No rated books yet. Rate a book from its detail page.' :
currentView === 'incomplete' ? 'No incomplete books — all books have Complete status.' :
currentView === 'genre' ? `No books tagged "${esc(currentParam || '')}".` :
currentView === 'search' ? `No results for "${esc(currentParam || '')}".` :
'No books yet. Import EPUB, PDF or CBR/CBZ to get started.'
}</div>`;
return;
}
// Reset lazy-load observer for this render pass.
// Handles both cover <img> elements (data-src) and placeholder <canvas> elements (data-t / data-a).
if (_coverObserver) _coverObserver.disconnect();
_coverObserver = new IntersectionObserver((entries) => {
for (const entry of entries) {
if (!entry.isIntersecting) continue;
const el = entry.target;
if (el.tagName === 'IMG') {
if (el.dataset.src) { el.src = el.dataset.src; delete el.dataset.src; }
} else {
requestAnimationFrame(() => {
makePlaceholderCover(el, el.dataset.t || '', el.dataset.a || '');
delete el.dataset.t; delete el.dataset.a;
});
}
_coverObserver.unobserve(el);
}
}, { rootMargin: '400px' });
const grid = document.createElement('div');
grid.className = 'cover-grid';
books.forEach(b => {
const author = bookAuthor(b);
const title = bookTitle(b);
const card = document.createElement('div');
card.className = 'book-card';
card.id = `card-${cssId(b.filename)}`;
const statusBadge = statusBadgeHtml(b.publication_status);
const starClass = b.want_to_read ? 'btn-star starred' : 'btn-star';
const seriesVol = seriesVolLabel(b, idxSeries);
const seriesText = b.series
? `${esc(b.series)}${seriesVol ? ' <span class="series-index">[' + esc(String(seriesVol)) + ']</span>' : ''}`
: '';
card.innerHTML = `
<div class="cover-wrap">
<canvas class="cover-canvas"></canvas>
<button class="${starClass}" id="star-${cssId(b.filename)}"
onclick="event.stopPropagation();toggleWtr('${jsEsc(b.filename)}')" title="Want to Read">
<svg width="11" height="11" viewBox="0 0 24 24" fill="${b.want_to_read ? 'currentColor' : 'none'}" stroke="currentColor" stroke-width="2.5" id="star-svg-${cssId(b.filename)}">
<polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"/>
</svg>
</button>
${statusBadge}
${b.read_count > 0 ? `<div class="read-pill">${b.read_count}\u00d7</div>` : ''}
${b.progress > 0 ? `<div class="progress-mini"><div class="progress-mini-fill" style="width:${b.progress}%"></div></div>` : ''}
</div>
${starsHtml(b.filename, b.rating)}
<div class="book-info">
<div class="book-title">${esc(title)}</div>
<div class="book-author">${esc(author)}</div>
${seriesText ? `<div class="book-series">${seriesText}</div>` : ''}
</div>`;
card.onclick = () => { location.href = `/library/book/${encodeURIComponent(b.filename)}`; };
// Single pass: set up cover using local querySelector — no second iteration needed
const wrap = card.querySelector('.cover-wrap');
const canvas = card.querySelector('.cover-canvas');
if (b.has_cover) {
const img = document.createElement('img');
img.className = 'cover-img';
img.style.cssText = 'position:absolute;inset:0;width:100%;height:100%;object-fit:cover';
img.alt = title;
if (b.has_cached_cover) canvas.style.display = 'none';
img.onload = () => { canvas.style.display = 'none'; };
img.onerror = () => { canvas.style.display = 'block'; makePlaceholderCover(canvas, title, author); };
img.dataset.src = `/library/cover-cached/${encodeURIComponent(b.filename)}`;
_coverObserver.observe(img);
wrap.insertBefore(img, wrap.firstChild);
}
if (!b.has_cover || !b.has_cached_cover) {
// Defer placeholder drawing until card enters viewport — avoids 1000+ upfront canvas ops
canvas.dataset.t = title;
canvas.dataset.a = author;
_coverObserver.observe(canvas);
}
grid.appendChild(card);
});
container.innerHTML = '';
container.appendChild(grid);
}
// ── Series grid ────────────────────────────────────────────────────────────
function groupBySeries() {
const map = {};
for (const b of activeBooks()) {
if (!b.series) continue;
if (!map[b.series]) map[b.series] = [];
map[b.series].push(b);
}
for (const s of Object.values(map)) s.sort((a, b) => {
if (a.series_index !== b.series_index) return a.series_index - b.series_index;
return (a.series_suffix || '').localeCompare(b.series_suffix || '');
});
return map;
}
function bookDotStatus(b) {
if (b.progress > 0) return 'reading';
if (b.read_count > 0) return 'read';
return 'unread';
}
function renderSeriesGrid() {
const map = groupBySeries();
const container = document.getElementById('grid-container');
const entries = Object.entries(map).sort(([a], [b]) => a.localeCompare(b));
if (!entries.length) {
container.innerHTML = '<div class="empty">No series found. Series metadata is read from the EPUB files.</div>';
return;
}
const grid = document.createElement('div');
grid.className = 'cover-grid';
entries.forEach(([seriesName, books]) => {
const card = document.createElement('div');
card.className = 'series-card';
card.onclick = () => switchView('series-detail', seriesName);
const dotStatuses = books.map(bookDotStatus);
const maxDots = 10;
const visibleDots = dotStatuses.slice(0, maxDots);
const extraDots = dotStatuses.length - maxDots;
const dotsHtml = visibleDots.map(s =>
`<span class="series-dot dot-${s}" title="${s}"></span>`
).join('') + (extraDots > 0 ? `<span style="font-family:var(--mono);font-size:0.55rem;color:var(--text-faint)">+${extraDots}</span>` : '');
const stackBooks = books.slice(0, 3).reverse();
const stackId = cssId(seriesName);
let stackHtml = '';
for (let i = 0; i < 3; i++) {
const depth = 3 - i;
const b = stackBooks[i];
if (b) {
stackHtml += `<div class="sci sci-${depth}" id="sci-${stackId}-${depth}">
<canvas id="sci-canvas-${stackId}-${depth}" style="width:100%;height:100%"></canvas>
</div>`;
}
}
card.innerHTML = `
<div class="series-cover-wrap">${stackHtml}</div>
<div class="series-info">
<div class="series-name">${esc(seriesName)}</div>
<div class="series-meta">
<span>${books.length} book${books.length !== 1 ? 's' : ''}</span>
<div class="series-dots">${dotsHtml}</div>
</div>
</div>`;
grid.appendChild(card);
});
container.innerHTML = '';
container.appendChild(grid);
entries.forEach(([seriesName, books]) => {
const stackBooks = books.slice(0, 3).reverse();
const stackId = cssId(seriesName);
for (let i = 0; i < stackBooks.length; i++) {
const depth = 3 - i;
const b = stackBooks[i];
const canvas = document.getElementById(`sci-canvas-${stackId}-${depth}`);
const wrap = document.getElementById(`sci-${stackId}-${depth}`);
if (!canvas || !wrap) continue;
const author = bookAuthor(b);
const title = bookTitle(b);
if (b.has_cover) {
const img = document.createElement('img');
img.style.cssText = 'position:absolute;inset:0;width:100%;height:100%;object-fit:cover';
img.src = `/library/cover/${encodeURIComponent(b.filename)}`;
img.alt = title;
img.onload = () => { canvas.style.display = 'none'; };
img.onerror = () => { requestAnimationFrame(() => makePlaceholderCover(canvas, title, author)); };
wrap.insertBefore(img, wrap.firstChild);
}
requestAnimationFrame(() => makePlaceholderCover(canvas, title, author));
}
});
}
// ── Series detail ──────────────────────────────────────────────────────────
function getSeriesSlots(books) {
// Treat books as indexed (including index 0) only when at least one book
// has series_index > 0 — this preserves the "unindexed flat list" behaviour
// for series where no indices were ever assigned.
const hasPositiveIndex = books.some(b => b.series_index > 0);
if (!hasPositiveIndex) return books;
// Sort indexed books by (series_index, series_suffix) so 21 < 21a < 21b < 22.
const indexed = [...books].sort((a, b) => {
if (a.series_index !== b.series_index) return a.series_index - b.series_index;
return (a.series_suffix || '').localeCompare(b.series_suffix || '');
});
// Build slot map keyed by numeric index only (for gap detection).
const byIndex = {};
for (const b of indexed) {
if (!byIndex[b.series_index]) byIndex[b.series_index] = [];
byIndex[b.series_index].push(b);
}
const min = Math.min(...indexed.map(b => b.series_index));
const max = Math.max(...indexed.map(b => b.series_index));
const slots = [];
for (let i = min; i <= max; i++) {
if (byIndex[i]) for (const b of byIndex[i]) slots.push(b);
else slots.push({ missing: true, series_index: i });
}
return slots;
}
function renderSeriesDetail(seriesName) {
const map = groupBySeries();
const books = map[seriesName] || [];
const hasPositiveIndex = books.some(b => b.series_index > 0);
const slots = getSeriesSlots(books);
const container = document.getElementById('grid-container');
if (!slots.length) {
container.innerHTML = '<div class="empty">No books found in this series.</div>';
return;
}
const grid = document.createElement('div');
grid.className = 'cover-grid';
slots.forEach(slot => {
const wrapper = document.createElement('div');
wrapper.className = 'series-slot' + (slot.missing ? ' slot-missing' : '');
if (hasPositiveIndex || slot.series_index > 0 || slot.series_suffix) {
const lbl = document.createElement('div');
lbl.className = 'slot-index-label';
lbl.textContent = `#${slot.series_index}${slot.series_suffix || ''}`;
wrapper.appendChild(lbl);
}
if (slot.missing) {
wrapper.innerHTML += `
<div class="cover-wrap">
<div class="slot-missing-inner">
<svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
<circle cx="12" cy="12" r="10"/>
<line x1="12" y1="8" x2="12" y2="12"/>
<line x1="12" y1="16" x2="12.01" y2="16"/>
</svg>
<span>Missing</span>
</div>
</div>
<div class="book-info">
<div class="book-title" style="color:var(--text-faint)">Volume ${slot.series_index}</div>
</div>`;
} else {
const b = slot;
const author = bookAuthor(b);
const title = bookTitle(b);
const cid = cssId(b.filename);
const statusBadge = statusBadgeHtml(b.publication_status);
const bookCard = document.createElement('div');
bookCard.className = 'book-card';
bookCard.style.cursor = 'pointer';
bookCard.onclick = () => { location.href = `/library/book/${encodeURIComponent(b.filename)}`; };
bookCard.innerHTML = `
<div class="cover-wrap">
<canvas class="cover-canvas"></canvas>
${statusBadge}
${b.read_count > 0 ? `<div class="read-pill">${b.read_count}\u00d7</div>` : ''}
${b.progress > 0 ? `<div class="progress-mini"><div class="progress-mini-fill" style="width:${b.progress}%"></div></div>` : ''}
</div>
${starsHtml(b.filename, b.rating)}
<div class="book-info">
<div class="book-title">${esc(title)}</div>
<div class="book-author">${esc(author)}</div>
</div>`;
// Single pass: set up cover using local querySelector
const wrap = bookCard.querySelector('.cover-wrap');
const canvas = bookCard.querySelector('.cover-canvas');
if (b.has_cover) {
const img = document.createElement('img');
img.style.cssText = 'position:absolute;inset:0;width:100%;height:100%;object-fit:cover';
img.src = `/library/cover/${encodeURIComponent(b.filename)}`;
img.alt = title;
img.onload = () => { canvas.style.display = 'none'; };
img.onerror = () => { requestAnimationFrame(() => makePlaceholderCover(canvas, title, author)); };
wrap.insertBefore(img, wrap.firstChild);
}
requestAnimationFrame(() => makePlaceholderCover(canvas, title, author));
wrapper.appendChild(bookCard);
}
grid.appendChild(wrapper);
});
container.innerHTML = '';
container.appendChild(grid);
}
// ── Authors list ───────────────────────────────────────────────────────────
function renderAuthorsView() {
const container = document.getElementById('grid-container');
const authorMap = {};
for (const b of activeBooks()) {
const a = bookAuthor(b);
if (!a) continue;
if (!authorMap[a]) authorMap[a] = [];
authorMap[a].push(b);
}
const entries = Object.entries(authorMap).sort(([a], [b]) => a.localeCompare(b));
if (!entries.length) {
container.innerHTML = '<div class="empty">No authors found.</div>';
return;
}
const list = document.createElement('div');
list.className = 'author-list';
entries.forEach(([authorName, books]) => {
const initial = authorName.trim()[0]?.toUpperCase() || '?';
const [bg, fg] = COVER_PALETTES[strHash(authorName) % COVER_PALETTES.length];
const item = document.createElement('div');
item.className = 'author-item';
item.onclick = () => switchView('author-detail', authorName);
item.innerHTML = `
<div class="author-avatar" style="background:${bg};color:${fg}">${esc(initial)}</div>
<div class="author-name">${esc(authorName)}</div>
<div class="author-count">${books.length} book${books.length !== 1 ? 's' : ''}</div>
<svg class="author-chevron" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="9 18 15 12 9 6"/>
</svg>`;
list.appendChild(item);
});
container.innerHTML = '';
container.appendChild(list);
}
function renderPublishersView() {
const container = document.getElementById('grid-container');
const publisherMap = {};
for (const b of activeBooks()) {
const key = bookPublisherKey(b);
if (!publisherMap[key]) publisherMap[key] = [];
publisherMap[key].push(b);
}
const missingBooks = publisherMap[MISSING_PUBLISHER_KEY] || [];
const filledEntries = Object.entries(publisherMap)
.filter(([key]) => key !== MISSING_PUBLISHER_KEY)
.sort(([a], [b]) => a.localeCompare(b));
if (!filledEntries.length && !missingBooks.length) {
container.innerHTML = '<div class="empty">No publishers found.</div>';
return;
}
const wrap = document.createElement('div');
wrap.className = 'publishers-wrap';
const missingList = document.createElement('div');
missingList.className = 'author-list publisher-missing-wrap';
const missingName = publisherDisplayName(MISSING_PUBLISHER_KEY);
const mInitial = missingName[0]?.toUpperCase() || '?';
const [mbg, mfg] = COVER_PALETTES[strHash(missingName) % COVER_PALETTES.length];
const missingItem = document.createElement('div');
missingItem.className = 'author-item publisher-missing-item';
missingItem.onclick = () => switchView('publisher-detail', MISSING_PUBLISHER_KEY);
missingItem.innerHTML = `
<div class="author-avatar" style="background:${mbg};color:${mfg}">${esc(mInitial)}</div>
<div class="author-name">${esc(missingName)}</div>
<div class="author-count">${missingBooks.length} book${missingBooks.length !== 1 ? 's' : ''}</div>
<svg class="author-chevron" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="9 18 15 12 9 6"/>
</svg>`;
missingList.appendChild(missingItem);
wrap.appendChild(missingList);
if (filledEntries.length) {
const divider = document.createElement('div');
divider.className = 'publisher-divider';
divider.textContent = 'Publishers';
wrap.appendChild(divider);
const list = document.createElement('div');
list.className = 'author-list';
filledEntries.forEach(([publisherKey, books]) => {
const publisherName = publisherDisplayName(publisherKey);
const initial = publisherName.trim()[0]?.toUpperCase() || '?';
const [bg, fg] = COVER_PALETTES[strHash(publisherName) % COVER_PALETTES.length];
const item = document.createElement('div');
item.className = 'author-item';
item.onclick = () => switchView('publisher-detail', publisherKey);
item.innerHTML = `
<div class="author-avatar" style="background:${bg};color:${fg}">${esc(initial)}</div>
<div class="author-name">${esc(publisherName)}</div>
<div class="author-count">${books.length} book${books.length !== 1 ? 's' : ''}</div>
<svg class="author-chevron" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="9 18 15 12 9 6"/>
</svg>`;
list.appendChild(item);
});
wrap.appendChild(list);
}
container.innerHTML = '';
container.appendChild(wrap);
}
// ── Genre view ─────────────────────────────────────────────────────────────
function renderGenreView(tag) {
const books = activeBooks().filter(b =>
bookGenres(b).includes(tag) ||
bookSubgenres(b).includes(tag) ||
bookPlainTags(b).includes(tag)
);
renderBooksGrid(books);
}
// ── Search ─────────────────────────────────────────────────────────────────
function renderSearchResults(query) {
if (!query) { renderBooksGrid(activeBooks()); return; }
const q = query.toLowerCase();
const books = activeBooks().filter(b =>
bookTitle(b).toLowerCase().includes(q) ||
bookAuthor(b).toLowerCase().includes(q) ||
bookGenres(b).some(g => g.toLowerCase().includes(q)) ||
bookSubgenres(b).some(g => g.toLowerCase().includes(q)) ||
bookPlainTags(b).some(g => g.toLowerCase().includes(q))
);
renderBooksGrid(books);
}
function clearSearch() {
document.getElementById('search-input').value = '';
document.getElementById('search-clear').style.display = 'none';
switchView('all');
}
// ── Bookmarks view ─────────────────────────────────────────────────────────
async function renderBookmarksView() {
const container = document.getElementById('grid-container');
container.innerHTML = '<div class="empty">Loading bookmarks…</div>';
let bookmarks;
try {
const resp = await fetch('/api/bookmarks');
bookmarks = await resp.json();
} catch {
container.innerHTML = '<div class="empty">Failed to load bookmarks.</div>';
return;
}
if (!bookmarks.length) {
container.innerHTML = '<div class="empty">No bookmarks yet. Tap the Bookmark button in the reader to save your place.</div>';
const el = document.getElementById('count-bookmarks');
if (el) el.textContent = '';
return;
}
const el = document.getElementById('count-bookmarks');
if (el) el.textContent = bookmarks.length || '';
container.innerHTML = bookmarks.map(bm => {
const date = bm.created_at ? _fmtDate(bm.created_at) : '';
const note = bm.note ? `<div class="bm-card-note">${esc(bm.note)}</div>` : '';
const coverUrl = `/library/cover/${encodeURIComponent(bm.filename)}`;
return `
<div class="bm-card" id="bmc-${bm.id}">
<a class="bm-card-cover" href="/library/read/${encodeURIComponent(bm.filename)}?bm_ch=${bm.chapter_index}&bm_scroll=${bm.scroll_frac.toFixed(4)}">
<img src="${coverUrl}" onerror="this.style.display='none'" alt=""/>
</a>
<div class="bm-card-body">
<div class="bm-card-book">${esc(bm.book_title)}</div>
<div class="bm-card-author">${esc(bm.book_author)}</div>
<div class="bm-card-chapter">${esc(bm.chapter_title)}</div>
${note}
<div class="bm-card-meta">${date}</div>
<div class="bm-card-actions">
<a class="bm-action-go btn-small" href="/library/read/${encodeURIComponent(bm.filename)}?bm_ch=${bm.chapter_index}&bm_scroll=${bm.scroll_frac.toFixed(4)}">
Go to bookmark
</a>
<button class="bm-action-del btn-small btn-danger" onclick="deleteBookmark(${bm.id})">Delete</button>
</div>
</div>
</div>`;
}).join('');
}
async function deleteBookmark(id) {
if (!confirm('Delete this bookmark?')) return;
const resp = await fetch(`/library/bookmarks/${id}`, { method: 'DELETE' });
const data = await resp.json();
if (data.ok) {
const card = document.getElementById(`bmc-${id}`);
if (card) card.remove();
// Update count
const remaining = document.querySelectorAll('.bm-card').length;
const el = document.getElementById('count-bookmarks');
if (el) el.textContent = remaining || '';
if (!remaining) {
document.getElementById('grid-container').innerHTML =
'<div class="empty">No bookmarks yet. Tap the Bookmark button in the reader to save your place.</div>';
}
} else {
alert('Could not delete bookmark.');
}
}
function _fmtDate(isoStr) {
try {
const s = /[Zz+\-]\d*$/.test(isoStr.trim()) ? isoStr : isoStr + 'Z';
return new Date(s).toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric' });
} catch { return ''; }
}
// ── Rated view ─────────────────────────────────────────────────────────────
function renderRatedView() {
const books = activeBooks()
.filter(b => b.rating > 0)
.sort((a, b) => (b.rating - a.rating) || bookTitle(a).localeCompare(bookTitle(b)));
renderBooksGrid(books);
}
// ── Duplicates view ────────────────────────────────────────────────────────
function _duplicateGroups(books) {
const map = new Map();
books.forEach(b => {
const key = (bookTitle(b).trim().toLowerCase()) + '|' + (bookAuthor(b).trim().toLowerCase());
if (!map.has(key)) map.set(key, []);
map.get(key).push(b);
});
return Array.from(map.values())
.filter(g => g.length >= 2)
.sort((a, b) => bookTitle(a[0]).localeCompare(bookTitle(b[0])));
}
function renderDuplicatesView() {
const container = document.getElementById('grid-container');
const groups = _duplicateGroups(activeBooks());
if (!groups.length) {
container.innerHTML = '<div class="empty">No duplicate books found.</div>';
return;
}
const idxSeries = indexedSeriesSet();
container.innerHTML = '';
groups.forEach(groupBooks => {
const heading = document.createElement('div');
heading.className = 'group-heading';
heading.textContent = `${bookTitle(groupBooks[0])}${bookAuthor(groupBooks[0])} (${groupBooks.length} copies)`;
container.appendChild(heading);
const grid = document.createElement('div');
grid.className = 'cover-grid';
groupBooks.forEach(b => {
const author = bookAuthor(b);
const title = bookTitle(b);
const card = document.createElement('div');
card.className = 'book-card';
card.id = `card-${cssId(b.filename)}`;
const statusBadge = statusBadgeHtml(b.publication_status);
const starClass = b.want_to_read ? 'btn-star starred' : 'btn-star';
card.innerHTML = `
<div class="cover-wrap">
<canvas class="cover-canvas"></canvas>
<button class="${starClass}" id="star-${cssId(b.filename)}"
onclick="event.stopPropagation();toggleWtr('${jsEsc(b.filename)}')" title="Want to Read">
<svg width="11" height="11" viewBox="0 0 24 24" fill="${b.want_to_read ? 'currentColor' : 'none'}" stroke="currentColor" stroke-width="2.5" id="star-svg-${cssId(b.filename)}">
<polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"/>
</svg>
</button>
${statusBadge}
${b.read_count > 0 ? `<div class="read-pill">${b.read_count}\u00d7</div>` : ''}
${b.progress > 0 ? `<div class="progress-mini"><div class="progress-mini-fill" style="width:${b.progress}%"></div></div>` : ''}
</div>
${starsHtml(b.filename, b.rating)}
<div class="book-info">
<div class="book-title">${esc(title)}</div>
<div class="book-author">${esc(author)}</div>
</div>`;
card.onclick = () => { location.href = `/library/book/${encodeURIComponent(b.filename)}`; };
// Single pass: set up cover using local querySelector
const wrap = card.querySelector('.cover-wrap');
const canvas = card.querySelector('.cover-canvas');
if (b.has_cover) {
const img = document.createElement('img');
img.className = 'cover-img';
img.style.cssText = 'position:absolute;inset:0;width:100%;height:100%;object-fit:cover';
img.alt = title;
if (b.has_cached_cover) canvas.style.display = 'none';
img.onload = () => { canvas.style.display = 'none'; };
img.onerror = () => { canvas.style.display = 'block'; makePlaceholderCover(canvas, title, author); };
img.dataset.src = `/library/cover-cached/${encodeURIComponent(b.filename)}`;
_coverObserver.observe(img);
wrap.insertBefore(img, wrap.firstChild);
}
if (!b.has_cover || !b.has_cached_cover) {
canvas.dataset.t = title;
canvas.dataset.a = author;
_coverObserver.observe(canvas);
}
grid.appendChild(card);
});
container.appendChild(grid);
});
}
// ── Author detail ──────────────────────────────────────────────────────────
function renderAuthorDetail(authorName) {
const books = allBooks
.filter(b => bookAuthor(b) === authorName)
.sort((a, b) => {
const sa = a.series || '\uffff';
const sb = b.series || '\uffff';
if (sa !== sb) return sa.localeCompare(sb);
if (a.series_index !== b.series_index) return a.series_index - b.series_index;
if ((a.series_suffix || '') !== (b.series_suffix || '')) return (a.series_suffix || '').localeCompare(b.series_suffix || '');
return bookTitle(a).localeCompare(bookTitle(b));
});
renderBooksGrid(books);
}
function renderPublisherDetail(publisherName) {
const books = allBooks
.filter(b => bookPublisherKey(b) === normalizePublisherName(publisherName))
.sort((a, b) => {
const sa = a.series || '\uffff';
const sb = b.series || '\uffff';
if (sa !== sb) return sa.localeCompare(sb);
if (a.series_index !== b.series_index) return a.series_index - b.series_index;
if ((a.series_suffix || '') !== (b.series_suffix || '')) return (a.series_suffix || '').localeCompare(b.series_suffix || '');
return bookTitle(a).localeCompare(bookTitle(b));
});
renderBooksGrid(books);
}
// ── Want to Read toggle ────────────────────────────────────────────────────
async function toggleWtr(filename) {
const resp = await fetch(`/library/want-to-read/${encodeURIComponent(filename)}`, { method: 'POST' });
const result = await resp.json();
if (result.error) return;
const book = allBooks.find(b => b.filename === filename);
if (book) book.want_to_read = result.want_to_read;
const id = cssId(filename);
const btn = document.getElementById(`star-${id}`);
const svg = document.getElementById(`star-svg-${id}`);
if (btn) btn.className = result.want_to_read ? 'btn-star starred' : 'btn-star';
if (svg) svg.setAttribute('fill', result.want_to_read ? 'currentColor' : 'none');
if (currentView === 'wtr' && !result.want_to_read) {
const card = document.getElementById(`card-${id}`);
if (card) card.remove();
const grid = document.querySelector('.cover-grid');
if (grid && !grid.children.length) {
document.getElementById('grid-container').innerHTML =
'<div class="empty">No books marked as Want to Read.</div>';
}
}
updateCounts();
}
// ── Delete ─────────────────────────────────────────────────────────────────
function askDelete(filename) {
pendingDelete = filename;
document.getElementById('confirm-filename').textContent = filename;
document.getElementById('confirm-overlay').classList.add('visible');
}
function closeConfirm() {
pendingDelete = null;
document.getElementById('confirm-overlay').classList.remove('visible');
}
async function confirmDelete() {
if (!pendingDelete) return;
const filename = pendingDelete;
closeConfirm();
await fetch(`/library/file/${encodeURIComponent(filename)}`, { method: 'DELETE' });
loadLibrary();
}
// ── Add cover ──────────────────────────────────────────────────────────────
function openCoverDialog(filename) {
coverTargetFilename = filename;
coverB64 = null;
document.getElementById('cover-target-filename').textContent = filename;
document.getElementById('cover-file-input').value = '';
document.getElementById('cover-dialog-preview').classList.remove('visible');
document.getElementById('cover-upload-prompt').textContent = 'Click to select a cover image';
document.getElementById('cover-upload-btn').disabled = true;
document.getElementById('cover-overlay').classList.add('visible');
}
function closeCoverDialog() {
coverTargetFilename = null;
coverB64 = null;
document.getElementById('cover-overlay').classList.remove('visible');
}
function onCoverFileSelected() {
const file = document.getElementById('cover-file-input').files[0];
if (!file) return;
const reader = new FileReader();
reader.onload = e => {
const dataUrl = e.target.result;
coverB64 = dataUrl.split(',')[1];
const preview = document.getElementById('cover-dialog-preview');
preview.src = dataUrl;
preview.classList.add('visible');
document.getElementById('cover-upload-prompt').textContent = file.name;
document.getElementById('cover-upload-btn').disabled = false;
};
reader.readAsDataURL(file);
}
async function uploadCover() {
if (!coverTargetFilename || !coverB64) return;
document.getElementById('cover-upload-btn').disabled = true;
document.getElementById('cover-upload-label').textContent = 'Uploading…';
document.getElementById('cover-spinner').style.display = 'inline-block';
const resp = await fetch(`/library/cover/${encodeURIComponent(coverTargetFilename)}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ cover_b64: coverB64 }),
});
const result = await resp.json();
document.getElementById('cover-upload-label').textContent = 'Add cover';
document.getElementById('cover-spinner').style.display = 'none';
if (result.error) {
alert('Error: ' + result.error);
document.getElementById('cover-upload-btn').disabled = false;
return;
}
closeCoverDialog();
loadLibrary();
}
// ── Rescan ─────────────────────────────────────────────────────────────────
async function rescanLibrary() {
const btn = document.getElementById('rescan-btn');
const label = document.getElementById('rescan-label');
btn.disabled = true;
label.textContent = 'Scanning…';
await fetch('/library/rescan', { method: 'POST' });
await loadLibrary();
btn.disabled = false;
label.textContent = 'Rescan library';
}
function openImportPicker() {
if (importInProgress) return;
const input = document.getElementById('import-file-input');
if (input) input.click();
}
function onImportFilesSelected(fileList) {
if (!fileList || !fileList.length) return;
const files = Array.from(fileList).filter(f => IMPORT_EXTENSIONS.some(ext => f.name.toLowerCase().endsWith(ext)));
if (!files.length) return;
uploadImportedFiles(files);
const input = document.getElementById('import-file-input');
if (input) input.value = '';
}
async function uploadImportedFiles(files) {
if (!files.length || importInProgress) return;
const zone = document.getElementById('import-dropzone');
const title = zone?.querySelector('.import-title');
const sub = zone?.querySelector('.import-sub');
importInProgress = true;
zone?.classList.add('uploading');
if (title) title.textContent = 'Importing files…';
if (sub) sub.textContent = `${files.length} file(s) selected`;
const form = new FormData();
files.forEach(f => form.append('files', f));
try {
const resp = await fetch('/library/import', { method: 'POST', body: form });
const data = await resp.json();
if (!resp.ok || data.error) {
alert(data.error || 'Import failed.');
} else {
const importedCount = (data.imported || []).length;
const skippedCount = (data.skipped || []).length;
if (title) title.textContent = importedCount
? `Imported ${importedCount} file(s)`
: 'No files imported';
if (sub) sub.textContent = skippedCount
? `${skippedCount} skipped`
: 'Ready for next import';
await loadLibrary();
}
} catch {
alert('Import failed.');
} finally {
importInProgress = false;
zone?.classList.remove('uploading');
setTimeout(() => {
if (title) title.textContent = 'Drop EPUB, PDF or CBR/CBZ files here';
if (sub) sub.textContent = 'or click to choose files';
}, 1200);
}
}
// ── Utilities ──────────────────────────────────────────────────────────────
function esc(s) {
return String(s ?? '').replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
}
function jsEsc(s) { return String(s ?? '').replace(/\\/g, '\\\\').replace(/'/g, "\\'"); }
function cssId(filename) { return filename.replace(/[^a-zA-Z0-9_-]/g, '_'); }
// ── Search input ───────────────────────────────────────────────────────────
let searchTimer = null;
document.getElementById('search-input').addEventListener('input', function() {
const q = this.value.trim();
document.getElementById('search-clear').style.display = q ? '' : 'none';
clearTimeout(searchTimer);
searchTimer = setTimeout(() => {
if (q) {
currentView = 'search';
currentParam = q;
['nav-all','nav-wtr','nav-new','nav-incomplete','nav-series','nav-authors','nav-publishers','nav-archived','nav-bookmarks','nav-rated','nav-duplicates'].forEach(id => {
const el = document.getElementById(id);
if (el) el.classList.remove('active');
});
document.getElementById('section-title').textContent = `Search: "${q}"`;
document.getElementById('back-btn').style.display = 'none';
renderGrid();
} else {
switchView('all');
}
}, 250);
});
// ── Init ───────────────────────────────────────────────────────────────────
const importZone = document.getElementById('import-dropzone');
if (importZone) {
['dragenter', 'dragover'].forEach(evt => {
importZone.addEventListener(evt, e => {
e.preventDefault();
e.stopPropagation();
if (!importInProgress) importZone.classList.add('dragover');
});
});
['dragleave', 'drop'].forEach(evt => {
importZone.addEventListener(evt, e => {
e.preventDefault();
e.stopPropagation();
importZone.classList.remove('dragover');
});
});
importZone.addEventListener('drop', e => {
if (importInProgress) return;
const files = Array.from(e.dataTransfer?.files || []).filter(f => IMPORT_EXTENSIONS.some(ext => f.name.toLowerCase().endsWith(ext)));
if (!files.length) return;
uploadImportedFiles(files);
});
}
document.addEventListener('click', e => {
const menu = document.getElementById('new-columns-menu');
if (!menu) return;
const toggleBtn = e.target && e.target.closest ? e.target.closest('.new-actions .btn-light') : null;
if (menu.contains(e.target) || toggleBtn) return;
menu.classList.remove('visible');
});
loadLibrary().then(() => {
const hash = window.location.hash.slice(1);
let view = 'all', param = null;
if (hash === 'wtr') view = 'wtr';
else if (hash === 'series') view = 'series';
else if (hash.startsWith('series/')) { view = 'series-detail'; param = decodeURIComponent(hash.slice(7)); }
else if (hash === 'authors') view = 'authors';
else if (hash.startsWith('authors/')) { view = 'author-detail'; param = decodeURIComponent(hash.slice(8)); }
else if (hash === 'publishers' || hash === 'publisher') view = 'publishers';
else if (hash.startsWith('publishers/')) { view = 'publisher-detail'; param = decodeURIComponent(hash.slice(11)); }
else if (hash.startsWith('publisher/')) { view = 'publisher-detail'; param = decodeURIComponent(hash.slice(10)); }
else if (hash === 'archived') view = 'archived';
else if (hash === 'new') view = 'new';
else if (hash === 'bookmarks') view = 'bookmarks';
else if (hash === 'rated') view = 'rated';
else if (hash === 'duplicates') view = 'duplicates';
else if (hash === 'incomplete') view = 'incomplete';
else if (hash.startsWith('genre/')) { view = 'genre'; param = decodeURIComponent(hash.slice(6)); }
history.replaceState({ view, param }, '', _viewUrl(view, param));
_applyView(view, param);
});