- Convert: warn when title+author already exists in library (preload check) - Library: Duplicates sidebar section with grouped view and live counter - Fix: Duplicates view cover loading now uses same canvas/two-pass pattern as renderBooksGrid - Docs: add TODO-PERF-library-load.md with four identified bottlenecks Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1895 lines
74 KiB
JavaScript
1895 lines
74 KiB
JavaScript
/* ── Novela — Library page script ─────────────────────────────────────── */
|
|
|
|
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 ───────────────────────────────────────────────────────────
|
|
|
|
async function loadLibrary() {
|
|
const resp = await fetch('/library/list');
|
|
allBooks = await resp.json();
|
|
updateCounts();
|
|
renderGrid();
|
|
return true;
|
|
}
|
|
|
|
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 === '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-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',
|
|
'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 === '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();
|
|
}
|
|
|
|
// ── 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;
|
|
|
|
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.');
|
|
return;
|
|
}
|
|
|
|
const selectedSet = new Set(selected);
|
|
allBooks.forEach(b => {
|
|
if (selectedSet.has(b.filename)) b.needs_review = false;
|
|
});
|
|
selected.forEach(f => newSelectedFilenames.delete(f));
|
|
updateCounts();
|
|
renderGrid();
|
|
} catch {
|
|
alert('Could not mark books as reviewed.');
|
|
} finally {
|
|
if (btn) btn.disabled = false;
|
|
}
|
|
}
|
|
|
|
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');
|
|
if (btn) btn.disabled = true;
|
|
for (const filename of filenames) {
|
|
try {
|
|
await fetch(`/library/file/${encodeURIComponent(filename)}`, { method: 'DELETE' });
|
|
} catch {}
|
|
}
|
|
closeBulkDeleteDialog();
|
|
allSelectedFilenames.clear();
|
|
allLastToggledIndex = null;
|
|
await loadLibrary();
|
|
}
|
|
|
|
// ── Book grid (All / WTR / Author detail) ─────────────────────────────────
|
|
|
|
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 === '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;
|
|
}
|
|
|
|
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 st = (b.publication_status || '').toLowerCase();
|
|
let statusBadge = '';
|
|
if (st === 'complete') {
|
|
statusBadge = `<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') {
|
|
statusBadge = `<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 === 'hiatus') {
|
|
statusBadge = `<div class="badge-status badge-hiatus" title="Hiatus">
|
|
<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>`;
|
|
}
|
|
|
|
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" id="wrap-${cssId(b.filename)}">
|
|
<canvas class="cover-canvas" id="canvas-${cssId(b.filename)}"></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)}`; };
|
|
|
|
grid.appendChild(card);
|
|
});
|
|
|
|
container.innerHTML = '';
|
|
container.appendChild(grid);
|
|
|
|
books.forEach(b => {
|
|
const author = bookAuthor(b);
|
|
const title = bookTitle(b);
|
|
const wrap = document.getElementById(`wrap-${cssId(b.filename)}`);
|
|
const canvas = document.getElementById(`canvas-${cssId(b.filename)}`);
|
|
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.src = `/library/cover-cached/${encodeURIComponent(b.filename)}`;
|
|
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);
|
|
};
|
|
wrap.insertBefore(img, wrap.firstChild);
|
|
}
|
|
if (!b.has_cover || !b.has_cached_cover) {
|
|
requestAnimationFrame(() => makePlaceholderCover(canvas, title, author));
|
|
}
|
|
});
|
|
}
|
|
|
|
// ── 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 st = (b.publication_status || '').toLowerCase();
|
|
let statusBadge = '';
|
|
if (st === 'complete') {
|
|
statusBadge = `<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') {
|
|
statusBadge = `<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 === 'hiatus') {
|
|
statusBadge = `<div class="badge-status badge-hiatus" title="Hiatus"><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>`;
|
|
}
|
|
|
|
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" id="wrap-${cid}">
|
|
<canvas class="cover-canvas" id="canvas-${cid}"></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>`;
|
|
wrapper.appendChild(bookCard);
|
|
}
|
|
|
|
grid.appendChild(wrapper);
|
|
});
|
|
|
|
container.innerHTML = '';
|
|
container.appendChild(grid);
|
|
|
|
slots.filter(s => !s.missing).forEach(b => {
|
|
const author = bookAuthor(b);
|
|
const title = bookTitle(b);
|
|
const canvas = document.getElementById(`canvas-${cssId(b.filename)}`);
|
|
const wrap = document.getElementById(`wrap-${cssId(b.filename)}`);
|
|
if (!canvas) return;
|
|
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));
|
|
});
|
|
}
|
|
|
|
// ── 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 st = (b.publication_status || '').toLowerCase();
|
|
let statusBadge = '';
|
|
if (st === 'complete') {
|
|
statusBadge = `<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') {
|
|
statusBadge = `<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 === 'hiatus') {
|
|
statusBadge = `<div class="badge-status badge-hiatus" title="Hiatus">
|
|
<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>`;
|
|
}
|
|
|
|
const starClass = b.want_to_read ? 'btn-star starred' : 'btn-star';
|
|
|
|
card.innerHTML = `
|
|
<div class="cover-wrap" id="wrap-${cssId(b.filename)}">
|
|
<canvas class="cover-canvas" id="canvas-${cssId(b.filename)}"></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)}`; };
|
|
|
|
grid.appendChild(card);
|
|
});
|
|
|
|
container.appendChild(grid);
|
|
|
|
// Second pass: load covers (same as renderBooksGrid)
|
|
groupBooks.forEach(b => {
|
|
const author = bookAuthor(b);
|
|
const title = bookTitle(b);
|
|
const wrap = document.getElementById(`wrap-${cssId(b.filename)}`);
|
|
const canvas = document.getElementById(`canvas-${cssId(b.filename)}`);
|
|
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.src = `/library/cover-cached/${encodeURIComponent(b.filename)}`;
|
|
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);
|
|
};
|
|
wrap.insertBefore(img, wrap.firstChild);
|
|
}
|
|
if (!b.has_cover || !b.has_cached_cover) {
|
|
requestAnimationFrame(() => makePlaceholderCover(canvas, title, author));
|
|
}
|
|
});
|
|
});
|
|
}
|
|
|
|
// ── 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, '&').replace(/</g, '<').replace(/>/g, '>');
|
|
}
|
|
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-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.startsWith('genre/')) { view = 'genre'; param = decodeURIComponent(hash.slice(6)); }
|
|
history.replaceState({ view, param }, '', _viewUrl(view, param));
|
|
_applyView(view, param);
|
|
});
|