- 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>
683 lines
26 KiB
HTML
683 lines
26 KiB
HTML
<!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,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"'); }
|
||
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>
|