novela/containers/novela/templates/home.html
Ivo Oskamp 92cd301658 Add PDF reader/editor support, fix metadata save and dir cleanup
- PDF reader: page-image rendering via /library/pdf/{filename}?page=N;
  new /api/pdf/info/{filename} endpoint returns page count; reader.html
  branches on FORMAT (epub/pdf) injected by server
- PDF metadata edit: PATCH /library/book now updates DB for all formats;
  _sync_epub_metadata only called for .epub; non-EPUB formats skip file write
- Fix file path on metadata save: _make_rel_path now includes format prefix
  (epub/, pdf/, comics/) matching common.make_rel_path used during import;
  previously files were moved outside their format directory
- Fix empty dir cleanup: prune_empty_dirs always runs after successful
  metadata save, not only when file was moved
- Hide Edit EPUB button for non-EPUB files in book detail
- Docs: TECHNICAL.md and changelog-develop.md updated

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-25 08:47:01 +01:00

683 lines
26 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<title>Novela — Home</title>
<link rel="preconnect" href="https://fonts.googleapis.com"/>
<link href="https://fonts.googleapis.com/css2?family=Libre+Baskerville:ital,wght@0,400;0,700;1,400&family=DM+Mono:wght@400;500&display=swap" rel="stylesheet"/>
<link rel="stylesheet" href="/static/sidebar.css"/>
<style>
:root {
--bg: #0f0e0c; --surface: #1a1815; --surface2: #221f1b;
--border: #2e2a24; --accent: #c8783a; --accent2: #e8a063;
--text: #e8e2d9; --text-dim: #8a8278; --text-faint: #4a453e;
--success: #6baa6b; --warning: #c8a03a; --error: #c85a3a;
--radius: 6px; --sidebar: 220px;
--mono: 'DM Mono', monospace; --serif: 'Libre Baskerville', Georgia, serif;
}
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
html, body { height: 100%; background: var(--bg); color: var(--text); font-family: var(--serif); }
.main { margin-left: var(--sidebar); min-height: 100vh; padding: 2rem 2.5rem 4rem; }
.main-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 1.75rem;
}
.main-title {
font-family: var(--mono);
font-size: 0.7rem;
letter-spacing: 0.12em;
text-transform: uppercase;
color: var(--accent);
}
.search-wrap { position: relative; display: flex; align-items: center; }
.search-icon { position: absolute; left: 0.5rem; color: var(--text-faint); pointer-events: none; }
.search-input {
background: var(--surface); border: 1px solid var(--border);
border-radius: var(--radius); color: var(--text);
font-family: var(--mono); font-size: 0.78rem;
padding: 0.4rem 1.8rem 0.4rem 2rem;
outline: none; width: 220px;
transition: border-color 0.15s, width 0.2s;
}
.search-input:focus { border-color: var(--accent); width: 280px; }
.search-input::placeholder { color: var(--text-faint); }
.search-clear {
position: absolute; right: 0.4rem;
background: none; border: none; color: var(--text-faint);
cursor: pointer; font-size: 1rem; line-height: 1; padding: 0 0.1rem;
}
.search-clear:hover { color: var(--text-dim); }
.import-dropzone {
border: 1px dashed var(--border);
background: rgba(34, 31, 27, 0.45);
border-radius: var(--radius);
padding: 0.9rem 1rem;
margin-bottom: 1.1rem;
cursor: pointer;
transition: border-color 0.15s, background 0.15s;
}
.import-dropzone:hover { border-color: var(--accent); }
.import-dropzone.dragover {
border-color: var(--accent2);
background: rgba(200, 120, 58, 0.12);
}
.import-dropzone.uploading {
opacity: 0.8;
cursor: progress;
}
.import-title {
font-family: var(--mono);
font-size: 0.72rem;
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--accent2);
}
.import-sub {
margin-top: 0.25rem;
font-family: var(--mono);
font-size: 0.68rem;
color: var(--text-dim);
}
.section-block { margin-bottom: 2.5rem; }
.section-header {
display: flex; align-items: baseline; justify-content: space-between;
margin-bottom: 1rem;
}
.section-title {
font-family: var(--mono); font-size: 0.7rem; letter-spacing: 0.12em;
text-transform: uppercase; color: var(--accent);
}
.section-sub { color: var(--text-dim); }
.section-more {
font-family: var(--mono); font-size: 0.65rem; color: var(--text-dim);
background: none; border: none; cursor: pointer; letter-spacing: 0.08em;
text-transform: uppercase; padding: 0;
transition: color 0.15s;
}
.section-more:hover { color: var(--accent); }
.h-row { display: flex; gap: 1rem; overflow-x: auto; padding-bottom: 0.75rem; }
.h-row::-webkit-scrollbar { height: 4px; }
.h-row::-webkit-scrollbar-thumb { background: var(--border); border-radius: 4px; }
.h-card {
flex: 0 0 120px; display: flex; flex-direction: column;
text-decoration: none; border-radius: var(--radius); overflow: hidden;
border: 1px solid var(--border); background: var(--surface);
transition: border-color 0.15s;
}
.h-card:hover { border-color: var(--accent); }
.h-cover {
position: relative; width: 120px; height: 180px;
flex-shrink: 0; background: var(--surface2);
}
.h-cover img, .h-cover canvas { width: 100%; height: 100%; object-fit: cover; display: block; }
.h-info { padding: 0.5rem 0.5rem 0.6rem; flex: 1; }
.h-title {
font-size: 0.72rem; font-weight: 700; color: var(--text); line-height: 1.3;
display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden;
margin-bottom: 0.3rem;
}
.h-author {
font-family: var(--mono); font-size: 0.6rem; color: var(--text-dim);
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.h-progress-bar { height: 3px; background: rgba(200,120,58,0.2); border-radius: 2px; margin-bottom: 0.25rem; }
.h-progress-fill { height: 100%; background: var(--accent); border-radius: 2px; }
.h-pct { font-family: var(--mono); font-size: 0.6rem; color: var(--text-dim); }
.grid-header {
display: flex; align-items: center; gap: 0.75rem; margin-bottom: 1.75rem;
}
.grid-title {
font-family: var(--mono); font-size: 0.7rem; letter-spacing: 0.12em;
text-transform: uppercase; color: var(--accent);
}
.btn-back {
display: inline-flex; align-items: center; gap: 0.35rem;
font-family: var(--mono); font-size: 0.65rem; letter-spacing: 0.08em;
text-transform: uppercase; color: var(--text-dim);
background: none; border: none; cursor: pointer; padding: 0;
transition: color 0.15s;
}
.btn-back:hover { color: var(--accent); }
.cover-grid {
display: grid; grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); gap: 1.5rem;
}
.book-card { display: flex; flex-direction: column; cursor: default; }
.cover-wrap {
position: relative; width: 100%; aspect-ratio: 2 / 3;
border-radius: var(--radius); overflow: hidden; background: var(--surface2);
}
.cover-link { display: block; width: 100%; height: 100%; }
.cover-img, .cover-canvas { width: 100%; height: 100%; object-fit: cover; display: block; }
.progress-mini {
position: absolute; bottom: 0; left: 0; right: 0;
height: 3px; z-index: 2; pointer-events: none;
background: rgba(200,120,58,0.25);
}
.progress-mini-fill { height: 100%; background: var(--accent); }
.book-info { padding: 0.5rem 0.2rem 0; }
.book-title {
font-size: 0.78rem; font-weight: 700; color: var(--text); line-height: 1.3;
display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden;
}
.book-author {
font-family: var(--mono); font-size: 0.65rem; color: var(--text-dim);
margin-top: 0.2rem; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.star-row { display: flex; gap: 0.1rem; margin-top: 0.3rem; padding: 0 0.1rem; }
.star { font-size: 0.72rem; color: rgba(200, 160, 58, 0.25); cursor: default; line-height: 1; transition: color 0.1s; user-select: none; }
.star.filled { color: var(--warning); }
.star-row.interactive .star { cursor: pointer; }
.star-row.interactive:hover .star { color: var(--accent2); }
.empty {
text-align: center; color: var(--text-faint); font-family: var(--mono);
font-size: 0.82rem; padding: 4rem 2rem;
}
@media (max-width: 768px) {
.main {
margin-left: 0;
padding: 4rem 1rem 4rem;
}
.main-header {
flex-wrap: wrap;
gap: 0.75rem;
margin-bottom: 1.25rem;
}
.cover-grid {
grid-template-columns: repeat(auto-fill, minmax(130px, 1fr));
gap: 1rem;
}
.search-input { width: 100%; }
.search-input:focus { width: 100%; }
.search-wrap { flex: 1; min-width: 0; }
}
</style>
</head>
<body>
{% include "_sidebar.html" %}
<main class="main">
<div class="main-header">
<div class="main-title">Home</div>
<div class="search-wrap">
<svg class="search-icon" width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
<circle cx="11" cy="11" r="8"/><path d="M21 21l-4.35-4.35"/>
</svg>
<input type="text" id="home-search-input" class="search-input" placeholder="Search title, author, genre..." autocomplete="off"/>
<button id="home-search-clear" class="search-clear" style="display:none" onclick="clearSearch()" title="Clear search">×</button>
</div>
</div>
<div class="import-dropzone" id="import-dropzone" onclick="openImportPicker()">
<input type="file" id="import-file-input" accept=".epub,.pdf,.cbr,.cbz,application/epub+zip,application/pdf" multiple style="display:none" onchange="onImportFilesSelected(this.files)"/>
<div class="import-title">Drop EPUB, PDF or CBR/CBZ files here</div>
<div class="import-sub">or click to choose files</div>
</div>
<div id="home-view">
<div class="section-block" id="cr-section" style="display:none">
<div class="section-header">
<div class="section-title">Continue Reading</div>
<button class="section-more" onclick="switchView('continue-reading')">See all</button>
</div>
<div class="h-row" id="cr-row"></div>
</div>
<div class="section-block" id="shorts-section" style="display:none">
<div class="section-header">
<div class="section-title">Shorts <span class="section-sub">· Unread</span></div>
<button class="section-more" onclick="switchView('shorts-unread')">See all</button>
</div>
<div class="h-row" id="shorts-row"></div>
</div>
<div class="section-block" id="novels-section" style="display:none">
<div class="section-header">
<div class="section-title">Novels <span class="section-sub">· Unread</span></div>
<button class="section-more" onclick="switchView('novels-unread')">See all</button>
</div>
<div class="h-row" id="novels-row"></div>
</div>
<div class="section-block" id="shorts-read-section" style="display:none">
<div class="section-header">
<div class="section-title">Shorts <span class="section-sub">· Recently Read</span></div>
<button class="section-more" onclick="switchView('shorts-read')">See all</button>
</div>
<div class="h-row" id="shorts-read-row"></div>
</div>
<div class="section-block" id="novels-read-section" style="display:none">
<div class="section-header">
<div class="section-title">Novels <span class="section-sub">· Recently Read</span></div>
<button class="section-more" onclick="switchView('novels-read')">See all</button>
</div>
<div class="h-row" id="novels-read-row"></div>
</div>
<div class="empty" id="home-empty" style="display:none">Nothing here yet - import some books to get started.</div>
</div>
<div id="grid-view" style="display:none">
<div class="grid-header">
<button class="btn-back" onclick="switchView('home')">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><polyline points="15 18 9 12 15 6"/></svg>
Back
</button>
<div class="grid-title" id="grid-title"></div>
</div>
<div class="cover-grid" id="grid-container"></div>
</div>
</main>
<script>
let data = { continue_reading: [], shorts_unread: [], novels_unread: [], shorts_read: [], novels_read: [] };
let currentView = 'home';
let importInProgress = false;
let searchTimer = null;
let allBooks = [];
const IMPORT_EXTENSIONS = ['.epub', '.pdf', '.cbr', '.cbz'];
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 PALETTES = [
['#1a2a3a','#4a8caa'],['#2a1a1a','#aa4a4a'],['#1a2a1a','#4aaa6a'],['#2a1a2a','#8a4aaa'],
['#2a2a1a','#aaa04a'],['#1a2a2a','#4aaa9a'],['#2a1a14','#c8783a'],['#141a2a','#5a78c8'],
];
function makePlaceholder(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] = PALETTES[strHash(title) % 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(trunc(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 = '', lines = [];
for (const w of words) {
const t = line ? line + ' ' + w : w;
if (ctx.measureText(t).width > maxW && line) { lines.push(line); line = w; } else line = t;
}
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 trunc(s, n) { return s.length > n ? s.slice(0, n - 1) + '…' : s; }
function esc(s) { return String(s || '').replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;'); }
function jsEsc(s) { return String(s || '').replace(/\\/g,'\\\\').replace(/'/g,"\\'"); }
function cssId(s) { return String(s || '').replace(/[^a-zA-Z0-9]/g, '_'); }
function starsHtml(filename, rating) {
const r = rating || 0;
const id = cssId(filename);
let html = `<div class="star-row" id="hstars-${id}">`;
for (let i = 1; i <= 5; i++) {
html += `<span class="star ${i <= r ? 'filled' : ''}">★</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(`hstars-${id}`);
if (row) row.outerHTML = starsHtml(filename, result.rating);
} catch {}
}
function filenameBase(filename) {
const leaf = String(filename || '').split('/').pop() || '';
return leaf.replace(/\.[^.]+$/, '');
}
function bookTitle(b) { return b.title || (filenameBase(b.filename).split('-')[2] ?? '').replace(/_/g, ' '); }
function bookAuthor(b) {
if (b.author) return b.author;
return (filenameBase(b.filename).split('-')[1] ?? '').replace(/_/g, ' ');
}
function attachCover(coverEl, canvasEl, b) {
const title = bookTitle(b);
const author = bookAuthor(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-cached/${encodeURIComponent(b.filename)}`;
img.alt = title;
img.onload = () => { canvasEl.style.display = 'none'; };
img.onerror = () => requestAnimationFrame(() => makePlaceholder(canvasEl, title, author));
coverEl.style.position = 'relative';
coverEl.insertBefore(img, coverEl.firstChild);
}
requestAnimationFrame(() => makePlaceholder(canvasEl, title, author));
}
function makeHCard(b, showProgress) {
const title = bookTitle(b);
const author = bookAuthor(b);
const id = cssId(b.filename);
const card = document.createElement('a');
card.className = 'h-card';
card.href = `/library/book/${encodeURIComponent(b.filename)}`;
card.title = title;
card.innerHTML = `
<div class="h-cover" id="hc-${id}">
<canvas id="hcv-${id}" style="width:100%;height:100%;display:block"></canvas>
</div>
${starsHtml(b.filename, b.rating)}
<div class="h-info">
<div class="h-title">${esc(title)}</div>
${showProgress
? `<div class="h-progress-bar"><div class="h-progress-fill" style="width:${b.progress}%"></div></div>
<div class="h-pct">${b.progress}%</div>`
: `<div class="h-author">${esc(author)}</div>`}
</div>`;
return card;
}
function makeGridCard(b) {
const title = bookTitle(b);
const author = bookAuthor(b);
const id = cssId(b.filename);
const card = document.createElement('div');
card.className = 'book-card';
card.innerHTML = `
<div class="cover-wrap">
<a class="cover-link" href="/library/book/${encodeURIComponent(b.filename)}">
<canvas id="gc-${id}" class="cover-canvas"></canvas>
${b.progress > 0
? `<div class="progress-mini"><div class="progress-mini-fill" style="width:${b.progress}%"></div></div>`
: ''}
</a>
</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>`;
return card;
}
function renderRow(rowEl, books, showProgress) {
rowEl.innerHTML = '';
books.slice(0, 20).forEach(b => {
const id = cssId(b.filename);
const card = makeHCard(b, showProgress);
rowEl.appendChild(card);
const coverEl = card.querySelector(`#hc-${id}`);
const canvasEl = card.querySelector(`#hcv-${id}`);
attachCover(coverEl, canvasEl, b);
});
}
function renderGrid(books) {
const container = document.getElementById('grid-container');
container.innerHTML = '';
if (!books.length) {
container.innerHTML = '<div class="empty">No books found.</div>';
return;
}
books.forEach(b => {
const id = cssId(b.filename);
const card = makeGridCard(b);
container.appendChild(card);
const canvas = card.querySelector(`#gc-${id}`);
if (b.has_cover) {
const img = document.createElement('img');
img.className = 'cover-img';
img.src = `/library/cover-cached/${encodeURIComponent(b.filename)}`;
img.alt = bookTitle(b);
img.onload = () => { canvas.style.display = 'none'; };
img.onerror = () => requestAnimationFrame(() => makePlaceholder(canvas, bookTitle(b), bookAuthor(b)));
img.style.cssText = 'position:absolute;inset:0;width:100%;height:100%;object-fit:cover';
canvas.parentElement.insertBefore(img, canvas);
}
requestAnimationFrame(() => makePlaceholder(canvas, bookTitle(b), bookAuthor(b)));
});
}
function searchResults(query) {
const q = String(query || '').trim().toLowerCase();
if (!q) return [];
return allBooks.filter(b =>
bookTitle(b).toLowerCase().includes(q) ||
bookAuthor(b).toLowerCase().includes(q) ||
(b.genres || []).some(g => String(g || '').toLowerCase().includes(q))
);
}
function switchView(view) {
currentView = view;
const homeView = document.getElementById('home-view');
const gridView = document.getElementById('grid-view');
const q = document.getElementById('home-search-input').value.trim();
if (view === 'home') {
homeView.style.display = '';
gridView.style.display = 'none';
return;
}
const titleMap = {
'continue-reading': 'Continue Reading',
'shorts-unread': 'Shorts · Unread',
'novels-unread': 'Novels · Unread',
'shorts-read': 'Shorts · Recently Read',
'novels-read': 'Novels · Recently Read',
};
const books =
view === 'continue-reading' ? data.continue_reading :
view === 'shorts-unread' ? data.shorts_unread :
view === 'novels-unread' ? data.novels_unread :
view === 'shorts-read' ? data.shorts_read :
view === 'novels-read' ? data.novels_read :
view === 'search' ? searchResults(q) : [];
document.getElementById('grid-title').textContent = view === 'search' ? `Search: "${q}"` : (titleMap[view] || '');
homeView.style.display = 'none';
gridView.style.display = '';
renderGrid(books);
}
function clearSearch() {
const input = document.getElementById('home-search-input');
const clear = document.getElementById('home-search-clear');
input.value = '';
clear.style.display = 'none';
if (currentView === 'search') switchView('home');
}
function setupSearch() {
const input = document.getElementById('home-search-input');
const clear = document.getElementById('home-search-clear');
input.addEventListener('input', () => {
const q = input.value.trim();
clear.style.display = q ? '' : 'none';
clearTimeout(searchTimer);
searchTimer = setTimeout(() => {
if (q) switchView('search');
else if (currentView === 'search') switchView('home');
}, 180);
});
}
function isImportableFile(file) {
const name = String(file?.name || '').toLowerCase();
return IMPORT_EXTENSIONS.some(ext => name.endsWith(ext));
}
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(isImportableFile);
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 payload = await resp.json();
if (!resp.ok || payload.error) {
alert(payload.error || 'Import failed.');
} else {
const importedCount = (payload.imported || []).length;
const skippedCount = (payload.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 init();
const q = document.getElementById('home-search-input').value.trim();
if (q) switchView('search');
}
} 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);
}
}
function setupImportDropzone() {
const zone = document.getElementById('import-dropzone');
if (!zone) return;
['dragenter', 'dragover'].forEach(evt => {
zone.addEventListener(evt, e => {
e.preventDefault();
e.stopPropagation();
if (!importInProgress) zone.classList.add('dragover');
});
});
['dragleave', 'drop'].forEach(evt => {
zone.addEventListener(evt, e => {
e.preventDefault();
e.stopPropagation();
zone.classList.remove('dragover');
});
});
zone.addEventListener('drop', e => {
if (importInProgress) return;
const files = Array.from(e.dataTransfer?.files || []).filter(isImportableFile);
if (!files.length) return;
uploadImportedFiles(files);
});
}
async function init() {
const [homeResp, libraryResp] = await Promise.all([
fetch('/api/home'),
fetch('/library/list'),
]);
data = await homeResp.json();
allBooks = (await libraryResp.json()).filter(b => !b.archived);
const crSection = document.getElementById('cr-section');
const shortsSection = document.getElementById('shorts-section');
const novelsSection = document.getElementById('novels-section');
const shortsReadSection = document.getElementById('shorts-read-section');
const novelsReadSection = document.getElementById('novels-read-section');
const homeEmpty = document.getElementById('home-empty');
crSection.style.display = 'none';
shortsSection.style.display = 'none';
novelsSection.style.display = 'none';
shortsReadSection.style.display = 'none';
novelsReadSection.style.display = 'none';
homeEmpty.style.display = 'none';
if (data.continue_reading.length) {
crSection.style.display = '';
renderRow(document.getElementById('cr-row'), data.continue_reading, true);
}
if (data.shorts_unread.length) {
shortsSection.style.display = '';
renderRow(document.getElementById('shorts-row'), data.shorts_unread, false);
}
if (data.novels_unread.length) {
novelsSection.style.display = '';
renderRow(document.getElementById('novels-row'), data.novels_unread, false);
}
if (data.shorts_read.length) {
shortsReadSection.style.display = '';
renderRow(document.getElementById('shorts-read-row'), data.shorts_read, false);
}
if (data.novels_read.length) {
novelsReadSection.style.display = '';
renderRow(document.getElementById('novels-read-row'), data.novels_read, false);
}
const hasAny = data.continue_reading.length || data.shorts_unread.length ||
data.novels_unread.length || data.shorts_read.length || data.novels_read.length;
if (!hasAny) homeEmpty.style.display = '';
}
setupSearch();
setupImportDropzone();
init();
</script>
</body>
</html>