Merge branch v20260327-01 into main

This commit is contained in:
Ivo Oskamp 2026-04-03 15:15:07 +02:00
commit f4ac7a7662
14 changed files with 838 additions and 76 deletions

View File

@ -11,6 +11,7 @@ from routers import (
backup_router, backup_router,
builder_router, builder_router,
editor_router, editor_router,
following_router,
grabber_router, grabber_router,
library_router, library_router,
reader_router, reader_router,
@ -40,6 +41,7 @@ app.include_router(grabber_router)
app.include_router(settings_router) app.include_router(settings_router)
app.include_router(backup_router) app.include_router(backup_router)
app.include_router(builder_router) app.include_router(builder_router)
app.include_router(following_router)
@app.get("/") @app.get("/")

View File

@ -278,6 +278,20 @@ def migrate_create_builder_drafts() -> None:
) )
def migrate_create_authors() -> None:
_exec(
"""
CREATE TABLE IF NOT EXISTS authors (
id SERIAL PRIMARY KEY,
name VARCHAR(255) UNIQUE NOT NULL,
url VARCHAR(1000),
created_at TIMESTAMP DEFAULT NOW(),
updated_at TIMESTAMP DEFAULT NOW()
)
"""
)
def run_migrations() -> None: def run_migrations() -> None:
migrate_create_library() migrate_create_library()
migrate_create_book_tags() migrate_create_book_tags()
@ -294,3 +308,4 @@ def run_migrations() -> None:
migrate_create_bookmarks() migrate_create_bookmarks()
migrate_series_suffix() migrate_series_suffix()
migrate_create_builder_drafts() migrate_create_builder_drafts()
migrate_create_authors()

View File

@ -1,6 +1,7 @@
from routers.backup import router as backup_router from routers.backup import router as backup_router
from routers.builder import router as builder_router from routers.builder import router as builder_router
from routers.editor import router as editor_router from routers.editor import router as editor_router
from routers.following import router as following_router
from routers.grabber import router as grabber_router from routers.grabber import router as grabber_router
from routers.library import router as library_router from routers.library import router as library_router
from routers.reader import router as reader_router from routers.reader import router as reader_router
@ -14,4 +15,5 @@ __all__ = [
"backup_router", "backup_router",
"settings_router", "settings_router",
"builder_router", "builder_router",
"following_router",
] ]

View File

@ -407,7 +407,10 @@ def list_library_json() -> list[dict]:
rs.last_read, rs.last_read,
(cc.filename IS NOT NULL) AS has_cached_cover, (cc.filename IS NOT NULL) AS has_cached_cover,
l.rating, l.rating,
COALESCE(l.series_suffix, '') AS series_suffix COALESCE(l.series_suffix, '') AS series_suffix,
json_agg(
json_build_object('tag', bt.tag, 'tag_type', bt.tag_type)
) FILTER (WHERE bt.tag IS NOT NULL) AS tags
FROM library l FROM library l
LEFT JOIN reading_progress rp ON rp.filename = l.filename LEFT JOIN reading_progress rp ON rp.filename = l.filename
LEFT JOIN ( LEFT JOIN (
@ -416,16 +419,17 @@ def list_library_json() -> list[dict]:
GROUP BY filename GROUP BY filename
) rs ON rs.filename = l.filename ) rs ON rs.filename = l.filename
LEFT JOIN library_cover_cache cc ON cc.filename = l.filename LEFT JOIN library_cover_cache cc ON cc.filename = l.filename
LEFT JOIN book_tags bt ON bt.filename = l.filename
GROUP BY l.filename, l.media_type, l.title, l.author, l.publisher, l.has_cover,
l.series, l.series_index, l.publication_status, l.want_to_read,
l.archived, l.needs_review, l.updated_at,
rp.progress, rp.cfi, rp.page,
rs.read_count, rs.last_read,
cc.filename, l.rating, l.series_suffix
ORDER BY COALESCE(l.publisher, ''), COALESCE(l.author, ''), COALESCE(l.series, ''), l.series_index, COALESCE(l.title, '') ORDER BY COALESCE(l.publisher, ''), COALESCE(l.author, ''), COALESCE(l.series, ''), l.series_index, COALESCE(l.title, '')
""" """
) )
rows = cur.fetchall() rows = cur.fetchall()
cur.execute("SELECT filename, tag, tag_type FROM book_tags ORDER BY filename, tag")
tags = cur.fetchall()
tag_map: dict[str, list[dict]] = {}
for filename, tag, tag_type in tags:
tag_map.setdefault(filename, []).append({"tag": tag, "tag_type": tag_type})
out = [] out = []
for r in rows: for r in rows:
@ -451,7 +455,7 @@ def list_library_json() -> list[dict]:
"page": r[15], "page": r[15],
"read_count": r[16] or 0, "read_count": r[16] or 0,
"last_read": r[17].isoformat() if r[17] else None, "last_read": r[17].isoformat() if r[17] else None,
"tags": tag_map.get(r[0], []), "tags": r[21] or [],
"rating": r[19] or 0, "rating": r[19] or 0,
} }
) )

View File

@ -0,0 +1,68 @@
from urllib.parse import unquote
from fastapi import APIRouter, Request
from fastapi.responses import HTMLResponse
from fastapi.templating import Jinja2Templates
from db import get_db_conn
templates = Jinja2Templates(directory="templates")
router = APIRouter()
@router.get("/following", response_class=HTMLResponse)
async def following_page(request: Request):
return templates.TemplateResponse(request, "following.html", {"active": "following"})
@router.get("/api/following")
async def get_following():
"""Return all distinct library authors with their URL (if any) and book stats."""
with get_db_conn() as conn:
with conn.cursor() as cur:
cur.execute(
"""
SELECT
l.author,
COUNT(l.filename)::int AS book_count,
MAX(l.created_at) AS last_added,
a.url
FROM library l
LEFT JOIN authors a ON a.name = l.author
WHERE l.author IS NOT NULL AND l.author <> '' AND NOT l.archived
GROUP BY l.author, a.url
ORDER BY l.author
"""
)
return [
{
"name": r[0],
"book_count": r[1],
"last_added": r[2].isoformat() if r[2] else None,
"url": r[3],
}
for r in cur.fetchall()
]
@router.post("/api/following/{author_name:path}")
async def set_author_url(author_name: str, request: Request):
"""Set or clear the URL for an author (empty url removes the entry)."""
author_name = unquote(author_name)
body = await request.json()
url = (body.get("url") or "").strip()
with get_db_conn() as conn:
with conn:
with conn.cursor() as cur:
if url:
cur.execute(
"""
INSERT INTO authors (name, url)
VALUES (%s, %s)
ON CONFLICT (name) DO UPDATE SET url = EXCLUDED.url, updated_at = NOW()
""",
(author_name, url),
)
else:
cur.execute("DELETE FROM authors WHERE name = %s", (author_name,))
return {"ok": True}

View File

@ -216,9 +216,27 @@ async def preload(request: Request):
book = await scraper.fetch_book_info(client, url) book = await scraper.fetch_book_info(client, url)
series = book.get("series", "") series = book.get("series", "")
hint = int(book.get("series_index_hint", 0) or 0) hint = int(book.get("series_index_hint", 0) or 0)
title = book.get("title", "")
author = book.get("author", "")
existing_books = []
if title or author:
with get_db_conn() as conn:
with conn.cursor() as cur:
cur.execute(
"""SELECT filename, title, author FROM library
WHERE LOWER(TRIM(title)) = LOWER(TRIM(%s))
AND LOWER(TRIM(author)) = LOWER(TRIM(%s))""",
(title, author),
)
existing_books = [
{"filename": r[0], "title": r[1] or "", "author": r[2] or ""}
for r in cur.fetchall()
]
return { return {
"title": book.get("title", ""), "title": title,
"author": book.get("author", ""), "author": author,
"publisher": book.get("publisher", ""), "publisher": book.get("publisher", ""),
"series": series, "series": series,
"series_index_next": hint if hint else _next_series_index(series), "series_index_next": hint if hint else _next_series_index(series),
@ -228,6 +246,8 @@ async def preload(request: Request):
"description": book.get("description", ""), "description": book.get("description", ""),
"updated_date": book.get("updated_date", ""), "updated_date": book.get("updated_date", ""),
"publication_status": book.get("publication_status", ""), "publication_status": book.get("publication_status", ""),
"already_exists": bool(existing_books),
"existing_books": existing_books,
} }

View File

@ -4,7 +4,7 @@ from datetime import datetime, timezone
from pathlib import Path from pathlib import Path
from fastapi import APIRouter, File, Request, UploadFile from fastapi import APIRouter, File, Request, UploadFile
from fastapi.responses import FileResponse, HTMLResponse, Response from fastapi.responses import FileResponse, HTMLResponse, JSONResponse, Response
from fastapi.templating import Jinja2Templates from fastapi.templating import Jinja2Templates
from PIL import UnidentifiedImageError from PIL import UnidentifiedImageError
@ -69,19 +69,33 @@ async def library_page(request: Request):
@router.get("/api/library") @router.get("/api/library")
async def api_library(rescan: bool = False, include_file_info: bool = False): async def api_library(
request: Request = None,
rescan: bool = False,
include_file_info: bool = False,
):
# Fast path: avoid expensive full disk scan on every library page load. # Fast path: avoid expensive full disk scan on every library page load.
# Use /library/rescan (or ?rescan=true) when a full sync is needed. # Use /library/rescan (or ?rescan=true) when a full sync is needed.
if rescan: if rescan:
_sync_disk_to_db() _sync_disk_to_db()
# ETag based on row count + latest updated_at — cheap query before full load.
with get_db_conn() as conn:
with conn.cursor() as cur:
cur.execute("SELECT COUNT(*), MAX(updated_at) FROM library")
_count, _max_ts = cur.fetchone()
etag = f'"{_count}-{int(_max_ts.timestamp()) if _max_ts else 0}"'
if request and request.headers.get("if-none-match") == etag:
return Response(status_code=304, headers={"ETag": etag, "Cache-Control": "no-cache"})
books = list_library_json() books = list_library_json()
if include_file_info: if include_file_info:
for b in books: for b in books:
p = resolve_library_path(b["filename"]) p = resolve_library_path(b["filename"])
if p and p.exists(): if p and p.exists():
b.update(relative_file_info(p)) b.update(relative_file_info(p))
return books return JSONResponse(content=books, headers={"ETag": etag, "Cache-Control": "no-cache"})
@router.post("/library/rescan") @router.post("/library/rescan")
@ -719,5 +733,5 @@ async def api_stats():
@router.get("/library/list") @router.get("/library/list")
async def library_list_compat(): async def library_list_compat(request: Request):
return await api_library() return await api_library(request)

View File

@ -59,6 +59,18 @@ html, body {
padding: 4rem 2rem; padding: 4rem 2rem;
} }
.group-heading {
font-family: var(--mono);
font-size: 0.72rem;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--text-dim);
padding: 1.5rem 0 0.5rem;
border-bottom: 1px solid var(--border);
margin-bottom: 0.75rem;
}
.group-heading:first-child { padding-top: 0; }
.import-dropzone { .import-dropzone {
border: 1px dashed var(--border); border: 1px dashed var(--border);
background: rgba(34, 31, 27, 0.45); background: rgba(34, 31, 27, 0.45);

View File

@ -107,12 +107,41 @@ function truncate(s, n) { return s.length > n ? s.slice(0, n - 1) + '…' : s; }
// ── Data loading ─────────────────────────────────────────────────────────── // ── Data loading ───────────────────────────────────────────────────────────
let _libraryETag = null;
async function loadLibrary() { async function loadLibrary() {
const resp = await fetch('/library/list'); try {
allBooks = await resp.json(); const headers = {};
updateCounts(); if (_libraryETag) headers['If-None-Match'] = _libraryETag;
renderGrid();
return true; const resp = await fetch('/library/list', { headers });
if (resp.status === 304) {
// Data unchanged — skip JSON parse, just re-render current view
updateCounts();
renderGrid();
return true;
}
if (!resp.ok) {
document.getElementById('grid-container').innerHTML =
`<div class="empty">Failed to load library (HTTP ${resp.status}). Check server logs.</div>`;
return false;
}
const etag = resp.headers.get('ETag');
if (etag) _libraryETag = etag;
allBooks = await resp.json();
updateCounts();
renderGrid();
return true;
} catch (err) {
console.error('loadLibrary error:', err);
document.getElementById('grid-container').innerHTML =
`<div class="empty">Failed to load library: ${String(err)}. Check browser console.</div>`;
return false;
}
} }
function activeBooks() { return allBooks.filter(b => !b.archived); } function activeBooks() { return allBooks.filter(b => !b.archived); }
@ -138,6 +167,10 @@ function updateCounts() {
const ratedCount = active.filter(b => b.rating > 0).length; const ratedCount = active.filter(b => b.rating > 0).length;
const ratedEl = document.getElementById('count-rated'); const ratedEl = document.getElementById('count-rated');
if (ratedEl) ratedEl.textContent = ratedCount || ''; 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) { function _filenameBase(filename) {
@ -184,6 +217,8 @@ function _viewUrl(view, param) {
if (view === 'archived') return '/library#archived'; if (view === 'archived') return '/library#archived';
if (view === 'bookmarks') return '/library#bookmarks'; if (view === 'bookmarks') return '/library#bookmarks';
if (view === 'rated') return '/library#rated'; if (view === 'rated') return '/library#rated';
if (view === 'duplicates') return '/library#duplicates';
if (view === 'incomplete') return '/library#incomplete';
if (view === 'new') return '/library#new'; if (view === 'new') return '/library#new';
if (view === 'genre') return '/library#genre/' + encodeURIComponent(param || ''); if (view === 'genre') return '/library#genre/' + encodeURIComponent(param || '');
return '/library'; return '/library';
@ -199,7 +234,7 @@ function _applyView(view, param) {
if (si) { si.value = ''; document.getElementById('search-clear').style.display = 'none'; } 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'].forEach(id => { ['nav-all','nav-wtr','nav-new','nav-incomplete','nav-series','nav-authors','nav-publishers','nav-archived','nav-bookmarks','nav-rated','nav-duplicates'].forEach(id => {
const el = document.getElementById(id); const el = document.getElementById(id);
if (el) el.classList.remove('active'); if (el) el.classList.remove('active');
}); });
@ -209,9 +244,11 @@ function _applyView(view, param) {
'authors': 'nav-authors', 'author-detail': 'nav-authors', 'authors': 'nav-authors', 'author-detail': 'nav-authors',
'publishers': 'nav-publishers', 'publisher-detail': 'nav-publishers', 'publishers': 'nav-publishers', 'publisher-detail': 'nav-publishers',
'new': 'nav-new', 'new': 'nav-new',
'incomplete': 'nav-incomplete',
'archived': 'nav-archived', 'archived': 'nav-archived',
'bookmarks': 'nav-bookmarks', 'bookmarks': 'nav-bookmarks',
'rated': 'nav-rated', 'rated': 'nav-rated',
'duplicates': 'nav-duplicates',
}; };
const el = document.getElementById(activeMap[view]); const el = document.getElementById(activeMap[view]);
if (el) el.classList.add('active'); if (el) el.classList.add('active');
@ -229,6 +266,8 @@ function _applyView(view, param) {
view === 'archived' ? 'Archived' : view === 'archived' ? 'Archived' :
view === 'bookmarks' ? 'Bookmarks' : view === 'bookmarks' ? 'Bookmarks' :
view === 'rated' ? 'Rated' : view === 'rated' ? 'Rated' :
view === 'duplicates' ? 'Duplicates' :
view === 'incomplete' ? 'Incomplete' :
view === 'genre' ? `Genre: ${param || ''}` : view === 'genre' ? `Genre: ${param || ''}` :
view === 'search' ? `Search: "${param || ''}"` : ''; view === 'search' ? `Search: "${param || ''}"` : '';
@ -279,6 +318,8 @@ function renderGrid() {
else if (currentView === 'search') renderSearchResults(currentParam); else if (currentView === 'search') renderSearchResults(currentParam);
else if (currentView === 'bookmarks') renderBookmarksView(); else if (currentView === 'bookmarks') renderBookmarksView();
else if (currentView === 'rated') renderRatedView(); else if (currentView === 'rated') renderRatedView();
else if (currentView === 'duplicates') renderDuplicatesView();
else if (currentView === 'incomplete') renderBooksGrid(active.filter(b => (b.publication_status || '').toLowerCase() !== 'complete'));
} }
// ── New view (bulk review + list/grid toggle) ───────────────────────────── // ── New view (bulk review + list/grid toggle) ─────────────────────────────
@ -893,6 +934,8 @@ async function confirmBulkDelete() {
// ── Book grid (All / WTR / Author detail) ───────────────────────────────── // ── Book grid (All / WTR / Author detail) ─────────────────────────────────
let _coverObserver = null;
function renderBooksGrid(books) { function renderBooksGrid(books) {
const container = document.getElementById('grid-container'); const container = document.getElementById('grid-container');
const idxSeries = indexedSeriesSet(); const idxSeries = indexedSeriesSet();
@ -902,7 +945,8 @@ function renderBooksGrid(books) {
currentView === 'wtr' ? 'No books marked as Want to Read. Star a book to add it here.' : 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 === 'archived' ? 'No archived books. Archive a book from its detail page.' :
currentView === 'new' ? 'No newly imported books waiting for metadata review.' : currentView === 'new' ? 'No newly imported books waiting for metadata review.' :
currentView === 'rated' ? 'No rated books yet. Rate a book from its detail page.' : currentView === 'rated' ? 'No rated books yet. Rate a book from its detail page.' :
currentView === 'incomplete' ? 'No incomplete books — all books have Complete status.' :
currentView === 'genre' ? `No books tagged "${esc(currentParam || '')}".` : currentView === 'genre' ? `No books tagged "${esc(currentParam || '')}".` :
currentView === 'search' ? `No results for "${esc(currentParam || '')}".` : currentView === 'search' ? `No results for "${esc(currentParam || '')}".` :
'No books yet. Import EPUB, PDF or CBR/CBZ to get started.' 'No books yet. Import EPUB, PDF or CBR/CBZ to get started.'
@ -910,6 +954,25 @@ function renderBooksGrid(books) {
return; return;
} }
// Reset lazy-load observer for this render pass.
// Handles both cover <img> elements (data-src) and placeholder <canvas> elements (data-t / data-a).
if (_coverObserver) _coverObserver.disconnect();
_coverObserver = new IntersectionObserver((entries) => {
for (const entry of entries) {
if (!entry.isIntersecting) continue;
const el = entry.target;
if (el.tagName === 'IMG') {
if (el.dataset.src) { el.src = el.dataset.src; delete el.dataset.src; }
} else {
requestAnimationFrame(() => {
makePlaceholderCover(el, el.dataset.t || '', el.dataset.a || '');
delete el.dataset.t; delete el.dataset.a;
});
}
_coverObserver.unobserve(el);
}
}, { rootMargin: '400px' });
const grid = document.createElement('div'); const grid = document.createElement('div');
grid.className = 'cover-grid'; grid.className = 'cover-grid';
@ -944,8 +1007,8 @@ function renderBooksGrid(books) {
: ''; : '';
card.innerHTML = ` card.innerHTML = `
<div class="cover-wrap" id="wrap-${cssId(b.filename)}"> <div class="cover-wrap">
<canvas class="cover-canvas" id="canvas-${cssId(b.filename)}"></canvas> <canvas class="cover-canvas"></canvas>
<button class="${starClass}" id="star-${cssId(b.filename)}" <button class="${starClass}" id="star-${cssId(b.filename)}"
onclick="event.stopPropagation();toggleWtr('${jsEsc(b.filename)}')" title="Want to Read"> 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)}"> <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)}">
@ -964,37 +1027,33 @@ function renderBooksGrid(books) {
</div>`; </div>`;
card.onclick = () => { location.href = `/library/book/${encodeURIComponent(b.filename)}`; }; card.onclick = () => { location.href = `/library/book/${encodeURIComponent(b.filename)}`; };
// Single pass: set up cover using local querySelector — no second iteration needed
const wrap = card.querySelector('.cover-wrap');
const canvas = card.querySelector('.cover-canvas');
if (b.has_cover) {
const img = document.createElement('img');
img.className = 'cover-img';
img.style.cssText = 'position:absolute;inset:0;width:100%;height:100%;object-fit:cover';
img.alt = title;
if (b.has_cached_cover) canvas.style.display = 'none';
img.onload = () => { canvas.style.display = 'none'; };
img.onerror = () => { canvas.style.display = 'block'; makePlaceholderCover(canvas, title, author); };
img.dataset.src = `/library/cover-cached/${encodeURIComponent(b.filename)}`;
_coverObserver.observe(img);
wrap.insertBefore(img, wrap.firstChild);
}
if (!b.has_cover || !b.has_cached_cover) {
// Defer placeholder drawing until card enters viewport — avoids 1000+ upfront canvas ops
canvas.dataset.t = title;
canvas.dataset.a = author;
_coverObserver.observe(canvas);
}
grid.appendChild(card); grid.appendChild(card);
}); });
container.innerHTML = ''; container.innerHTML = '';
container.appendChild(grid); 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 ──────────────────────────────────────────────────────────── // ── Series grid ────────────────────────────────────────────────────────────
@ -1193,8 +1252,8 @@ function renderSeriesDetail(seriesName) {
bookCard.style.cursor = 'pointer'; bookCard.style.cursor = 'pointer';
bookCard.onclick = () => { location.href = `/library/book/${encodeURIComponent(b.filename)}`; }; bookCard.onclick = () => { location.href = `/library/book/${encodeURIComponent(b.filename)}`; };
bookCard.innerHTML = ` bookCard.innerHTML = `
<div class="cover-wrap" id="wrap-${cid}"> <div class="cover-wrap">
<canvas class="cover-canvas" id="canvas-${cid}"></canvas> <canvas class="cover-canvas"></canvas>
${statusBadge} ${statusBadge}
${b.read_count > 0 ? `<div class="read-pill">${b.read_count}\u00d7</div>` : ''} ${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>` : ''} ${b.progress > 0 ? `<div class="progress-mini"><div class="progress-mini-fill" style="width:${b.progress}%"></div></div>` : ''}
@ -1204,6 +1263,21 @@ function renderSeriesDetail(seriesName) {
<div class="book-title">${esc(title)}</div> <div class="book-title">${esc(title)}</div>
<div class="book-author">${esc(author)}</div> <div class="book-author">${esc(author)}</div>
</div>`; </div>`;
// Single pass: set up cover using local querySelector
const wrap = bookCard.querySelector('.cover-wrap');
const canvas = bookCard.querySelector('.cover-canvas');
if (b.has_cover) {
const img = document.createElement('img');
img.style.cssText = 'position:absolute;inset:0;width:100%;height:100%;object-fit:cover';
img.src = `/library/cover/${encodeURIComponent(b.filename)}`;
img.alt = title;
img.onload = () => { canvas.style.display = 'none'; };
img.onerror = () => { requestAnimationFrame(() => makePlaceholderCover(canvas, title, author)); };
wrap.insertBefore(img, wrap.firstChild);
}
requestAnimationFrame(() => makePlaceholderCover(canvas, title, author));
wrapper.appendChild(bookCard); wrapper.appendChild(bookCard);
} }
@ -1212,24 +1286,6 @@ function renderSeriesDetail(seriesName) {
container.innerHTML = ''; container.innerHTML = '';
container.appendChild(grid); 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 ─────────────────────────────────────────────────────────── // ── Authors list ───────────────────────────────────────────────────────────
@ -1473,6 +1529,115 @@ function renderRatedView() {
renderBooksGrid(books); 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">
<canvas class="cover-canvas"></canvas>
<button class="${starClass}" id="star-${cssId(b.filename)}"
onclick="event.stopPropagation();toggleWtr('${jsEsc(b.filename)}')" title="Want to Read">
<svg width="11" height="11" viewBox="0 0 24 24" fill="${b.want_to_read ? 'currentColor' : 'none'}" stroke="currentColor" stroke-width="2.5" id="star-svg-${cssId(b.filename)}">
<polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"/>
</svg>
</button>
${statusBadge}
${b.read_count > 0 ? `<div class="read-pill">${b.read_count}\u00d7</div>` : ''}
${b.progress > 0 ? `<div class="progress-mini"><div class="progress-mini-fill" style="width:${b.progress}%"></div></div>` : ''}
</div>
${starsHtml(b.filename, b.rating)}
<div class="book-info">
<div class="book-title">${esc(title)}</div>
<div class="book-author">${esc(author)}</div>
</div>`;
card.onclick = () => { location.href = `/library/book/${encodeURIComponent(b.filename)}`; };
// Single pass: set up cover using local querySelector
const wrap = card.querySelector('.cover-wrap');
const canvas = card.querySelector('.cover-canvas');
if (b.has_cover) {
const img = document.createElement('img');
img.className = 'cover-img';
img.style.cssText = 'position:absolute;inset:0;width:100%;height:100%;object-fit:cover';
img.alt = title;
if (b.has_cached_cover) canvas.style.display = 'none';
img.onload = () => { canvas.style.display = 'none'; };
img.onerror = () => { canvas.style.display = 'block'; makePlaceholderCover(canvas, title, author); };
img.dataset.src = `/library/cover-cached/${encodeURIComponent(b.filename)}`;
_coverObserver.observe(img);
wrap.insertBefore(img, wrap.firstChild);
}
if (!b.has_cover || !b.has_cached_cover) {
canvas.dataset.t = title;
canvas.dataset.a = author;
_coverObserver.observe(canvas);
}
grid.appendChild(card);
});
container.appendChild(grid);
});
}
// ── Author detail ────────────────────────────────────────────────────────── // ── Author detail ──────────────────────────────────────────────────────────
function renderAuthorDetail(authorName) { function renderAuthorDetail(authorName) {
@ -1702,7 +1867,7 @@ document.getElementById('search-input').addEventListener('input', function() {
if (q) { if (q) {
currentView = 'search'; currentView = 'search';
currentParam = q; currentParam = q;
['nav-all','nav-wtr','nav-new','nav-series','nav-authors','nav-publishers','nav-archived','nav-bookmarks','nav-rated'].forEach(id => { ['nav-all','nav-wtr','nav-new','nav-incomplete','nav-series','nav-authors','nav-publishers','nav-archived','nav-bookmarks','nav-rated','nav-duplicates'].forEach(id => {
const el = document.getElementById(id); const el = document.getElementById(id);
if (el) el.classList.remove('active'); if (el) el.classList.remove('active');
}); });
@ -1764,6 +1929,8 @@ loadLibrary().then(() => {
else if (hash === 'new') view = 'new'; else if (hash === 'new') view = 'new';
else if (hash === 'bookmarks') view = 'bookmarks'; else if (hash === 'bookmarks') view = 'bookmarks';
else if (hash === 'rated') view = 'rated'; else if (hash === 'rated') view = 'rated';
else if (hash === 'duplicates') view = 'duplicates';
else if (hash === 'incomplete') view = 'incomplete';
else if (hash.startsWith('genre/')) { view = 'genre'; param = decodeURIComponent(hash.slice(6)); } else if (hash.startsWith('genre/')) { view = 'genre'; param = decodeURIComponent(hash.slice(6)); }
history.replaceState({ view, param }, '', _viewUrl(view, param)); history.replaceState({ view, param }, '', _viewUrl(view, param));
_applyView(view, param); _applyView(view, param);

View File

@ -60,6 +60,16 @@
<span class="sidebar-count" id="count-new"></span> <span class="sidebar-count" id="count-new"></span>
</a> </a>
</li> </li>
<li>
<a href="{% if active == 'library' %}#{% else %}/library#incomplete{% endif %}"
{% if active == 'library' %}id="nav-incomplete" onclick="switchView('incomplete'); return false;"{% endif %}>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/>
</svg>
Incomplete
<span class="sidebar-count" id="count-incomplete"></span>
</a>
</li>
<li> <li>
<a href="{% if active == 'library' %}#{% else %}/library#series{% endif %}" <a href="{% if active == 'library' %}#{% else %}/library#series{% endif %}"
{% if active == 'library' %}id="nav-series" onclick="switchView('series'); return false;"{% endif %}> {% if active == 'library' %}id="nav-series" onclick="switchView('series'); return false;"{% endif %}>
@ -129,6 +139,16 @@
<span class="sidebar-count" id="count-rated"></span> <span class="sidebar-count" id="count-rated"></span>
</a> </a>
</li> </li>
<li>
<a href="{% if active == 'library' %}#{% else %}/library#duplicates{% endif %}"
{% if active == 'library' %}id="nav-duplicates" onclick="switchView('duplicates'); return false;"{% endif %}>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1"/>
</svg>
Duplicates
<span class="sidebar-count" id="count-duplicates"></span>
</a>
</li>
<li> <li>
<a href="/stats"{% if active == 'stats' %} class="active"{% endif %}> <a href="/stats"{% if active == 'stats' %} class="active"{% endif %}>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
@ -143,6 +163,24 @@
<hr class="sidebar-divider"/> <hr class="sidebar-divider"/>
<div class="sidebar-section-label">Following</div>
<ul class="sidebar-nav">
<li>
<a href="/following"{% if active == 'following' %} class="active"{% endif %}>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/>
<circle cx="12" cy="7" r="4"/>
<line x1="19" y1="8" x2="19" y2="14"/>
<line x1="22" y1="11" x2="16" y2="11"/>
</svg>
Authors
<span class="sidebar-count" id="count-following"></span>
</a>
</li>
</ul>
<hr class="sidebar-divider"/>
<div class="sidebar-section-label">Tools</div> <div class="sidebar-section-label">Tools</div>
<ul class="sidebar-nav"> <ul class="sidebar-nav">
<li> <li>
@ -234,8 +272,18 @@
const seriesCount = new Set(active.filter(b => b.series).map(b => b.series)).size; const seriesCount = new Set(active.filter(b => b.series).map(b => b.series)).size;
const authorCount = new Set(active.map(b => b.author).filter(Boolean)).size; const authorCount = new Set(active.map(b => b.author).filter(Boolean)).size;
const publisherCount = new Set(active.map(b => b.publisher).filter(Boolean)).size; const publisherCount = new Set(active.map(b => b.publisher).filter(Boolean)).size;
const archivedCount = books.filter(b => b.archived).length; const archivedCount = books.filter(b => b.archived).length;
const ratedCount = active.filter(b => b.rating > 0).length; const ratedCount = active.filter(b => b.rating > 0).length;
const incompleteCount = active.filter(b => (b.publication_status || '').toLowerCase() !== 'complete').length;
const dupMap = new Map();
active.forEach(b => {
const key = (b.title || '').trim().toLowerCase() + '|' + (b.author || '').trim().toLowerCase();
dupMap.set(key, (dupMap.get(key) || 0) + 1);
});
const dupCount = active.filter(b => {
const key = (b.title || '').trim().toLowerCase() + '|' + (b.author || '').trim().toLowerCase();
return dupMap.get(key) >= 2;
}).length;
const setCount = (id, value) => { const setCount = (id, value) => {
const el = document.getElementById(id); const el = document.getElementById(id);
@ -250,6 +298,8 @@
setCount('count-publishers', publisherCount); setCount('count-publishers', publisherCount);
setCount('count-rated', ratedCount); setCount('count-rated', ratedCount);
setCount('count-archived', archivedCount); setCount('count-archived', archivedCount);
setCount('count-duplicates', dupCount);
setCount('count-incomplete', incompleteCount);
} }
async function refreshLibraryCounts() { async function refreshLibraryCounts() {
@ -363,7 +413,18 @@
} catch (_) {} } catch (_) {}
} }
async function refreshFollowingCount() {
try {
const resp = await fetch('/api/following');
if (!resp.ok) return;
const authors = await resp.json();
const el = document.getElementById('count-following');
if (el) el.textContent = authors.filter(a => a.url).length || '';
} catch (_) {}
}
refreshLibraryCounts(); refreshLibraryCounts();
refreshBookmarkCount(); refreshBookmarkCount();
refreshFollowingCount();
loadBackupStatus(); loadBackupStatus();
</script> </script>

View File

@ -0,0 +1,324 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<title>Novela — Following</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; }
@media (max-width: 768px) { .main { margin-left: 0; padding: 4rem 1rem 4rem; } }
.main-header {
display: flex; align-items: center; justify-content: space-between;
margin-bottom: 1.75rem; flex-wrap: wrap; gap: 1rem;
}
.main-title {
font-family: var(--mono); font-size: 0.7rem; letter-spacing: 0.12em;
text-transform: uppercase; color: var(--accent);
}
.filter-tabs { display: flex; gap: 0.5rem; }
.tab {
font-family: var(--mono); font-size: 0.72rem; padding: 0.3rem 0.75rem;
border: 1px solid var(--border); border-radius: var(--radius);
background: var(--surface); color: var(--text-dim); cursor: pointer;
transition: border-color 0.15s, color 0.15s;
}
.tab.active { border-color: var(--accent); color: var(--accent); }
.tab .cnt { color: var(--text-faint); margin-left: 0.3rem; }
.author-list { display: flex; flex-direction: column; gap: 0.5rem; max-width: 860px; }
.author-row {
display: flex; align-items: center; gap: 1rem;
background: var(--surface); border: 1px solid var(--border);
border-radius: var(--radius); padding: 0.75rem 1rem;
transition: border-color 0.15s;
}
.author-row:hover { border-color: var(--border); }
.author-row.has-url:hover { border-color: var(--accent); }
.author-avatar {
width: 36px; height: 36px; border-radius: 50%; flex-shrink: 0;
display: flex; align-items: center; justify-content: center;
font-family: var(--serif); font-size: 0.95rem; font-weight: bold;
}
.author-info { flex: 1; min-width: 0; }
.author-name-link {
font-family: var(--serif); font-size: 0.92rem; color: var(--text);
text-decoration: none;
}
.author-name-link:hover { color: var(--accent2); }
.author-meta {
font-family: var(--mono); font-size: 0.68rem; color: var(--text-dim);
margin-top: 0.15rem;
}
.author-url-area { display: flex; align-items: center; gap: 0.5rem; flex-shrink: 0; }
.url-display {
font-family: var(--mono); font-size: 0.68rem; color: var(--text-dim);
max-width: 200px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.url-display a { color: var(--text-dim); text-decoration: none; }
.url-display a:hover { color: var(--accent2); text-decoration: underline; }
.no-url-label {
font-family: var(--mono); font-size: 0.68rem; color: var(--text-faint);
}
.btn-visit {
font-family: var(--mono); font-size: 0.7rem; padding: 0.25rem 0.6rem;
border: 1px solid var(--border); border-radius: var(--radius);
background: transparent; color: var(--text-dim); cursor: pointer;
white-space: nowrap; transition: border-color 0.15s, color 0.15s;
}
.btn-visit:hover { border-color: var(--accent); color: var(--accent); }
.btn-edit {
font-family: var(--mono); font-size: 0.7rem; padding: 0.25rem 0.6rem;
border: 1px solid transparent; border-radius: var(--radius);
background: transparent; color: var(--text-faint); cursor: pointer;
white-space: nowrap; transition: border-color 0.15s, color 0.15s;
}
.btn-edit:hover { border-color: var(--border); color: var(--text-dim); }
.url-edit-form { display: flex; align-items: center; gap: 0.5rem; }
.url-input {
font-family: var(--mono); font-size: 0.72rem; width: 280px;
background: var(--surface2); border: 1px solid var(--accent);
border-radius: var(--radius); color: var(--text);
padding: 0.25rem 0.5rem; outline: none;
}
@media (max-width: 600px) { .url-input { width: 160px; } }
.btn-save {
font-family: var(--mono); font-size: 0.7rem; padding: 0.25rem 0.6rem;
border: 1px solid var(--success); border-radius: var(--radius);
background: transparent; color: var(--success); cursor: pointer;
}
.btn-save:hover { background: var(--success); color: var(--bg); }
.btn-cancel {
font-family: var(--mono); font-size: 0.7rem; padding: 0.25rem 0.6rem;
border: 1px solid var(--border); border-radius: var(--radius);
background: transparent; color: var(--text-dim); cursor: pointer;
}
.empty, .loading {
font-family: var(--mono); font-size: 0.8rem; color: var(--text-dim);
padding: 2rem 0;
}
</style>
</head>
<body>
{% include "_sidebar.html" %}
<main class="main">
<div class="main-header">
<div class="main-title">Following</div>
<div class="filter-tabs">
<button id="tab-following" class="tab active" onclick="setFilter('following')">
Following <span class="cnt" id="cnt-following">0</span>
</button>
<button id="tab-all" class="tab" onclick="setFilter('all')">
All Authors <span class="cnt" id="cnt-all">0</span>
</button>
</div>
</div>
<div id="author-list"><div class="loading">Loading…</div></div>
</main>
<script>
const COVER_PALETTES = [
['#1a2a3a','#4a8caa'],['#2a1a1a','#aa4a4a'],['#1a2a1a','#4aaa6a'],
['#2a1a2a','#8a4aaa'],['#2a2a1a','#aaa04a'],['#1a2a2a','#4aaa9a'],
['#2a1a14','#c8783a'],['#141a2a','#5a78c8'],
];
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);
}
function esc(s) {
return (s || '').replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
}
function timeAgo(isoStr) {
if (!isoStr) return '';
const s = /[Zz+\-]\d*$/.test(isoStr.trim()) ? isoStr : isoStr + 'Z';
const diff = Math.floor((Date.now() - new Date(s).getTime()) / 1000);
if (diff < 60) return 'just now';
if (diff < 3600) return Math.floor(diff / 60) + 'm ago';
if (diff < 86400) return Math.floor(diff / 3600) + 'h ago';
if (diff < 604800) return Math.floor(diff / 86400) + 'd ago';
if (diff < 2592000) return Math.floor(diff / 604800) + 'w ago';
return Math.floor(diff / 2592000) + 'mo ago';
}
function hostOf(url) {
try { return new URL(url).hostname.replace(/^www\./, ''); } catch (_) { return url; }
}
let allAuthors = [];
let currentFilter = 'following';
async function loadAuthors() {
try {
const resp = await fetch('/api/following');
allAuthors = await resp.json();
} catch (_) {
allAuthors = [];
}
renderList();
updateCounts();
}
function updateCounts() {
const followingCount = allAuthors.filter(a => a.url).length;
document.getElementById('cnt-following').textContent = followingCount;
document.getElementById('cnt-all').textContent = allAuthors.length;
const sidebarEl = document.getElementById('count-following');
if (sidebarEl) sidebarEl.textContent = followingCount || '';
}
function setFilter(f) {
currentFilter = f;
document.getElementById('tab-following').classList.toggle('active', f === 'following');
document.getElementById('tab-all').classList.toggle('active', f === 'all');
renderList();
}
function renderList() {
const container = document.getElementById('author-list');
const items = currentFilter === 'following'
? allAuthors.filter(a => a.url)
: allAuthors;
if (!items.length) {
container.innerHTML = `<div class="empty">${
currentFilter === 'following'
? 'No authors followed yet. Switch to "All Authors" to add URLs.'
: 'No authors in your library yet.'
}</div>`;
return;
}
const list = document.createElement('div');
list.className = 'author-list';
items.forEach(a => list.appendChild(makeRow(a)));
container.innerHTML = '';
container.appendChild(list);
}
function makeRow(author) {
const [bg, fg] = COVER_PALETTES[strHash(author.name) % COVER_PALETTES.length];
const initial = (author.name.trim()[0] || '?').toUpperCase();
const books = author.book_count;
const meta = books + ' book' + (books !== 1 ? 's' : '') + (author.last_added ? ' · ' + timeAgo(author.last_added) : '');
const row = document.createElement('div');
row.className = 'author-row' + (author.url ? ' has-url' : '');
row.dataset.name = author.name;
row.dataset.url = author.url || '';
row.innerHTML = `
<div class="author-avatar" style="background:${bg};color:${fg}">${esc(initial)}</div>
<div class="author-info">
<a class="author-name-link" href="/library#authors/${encodeURIComponent(author.name)}">${esc(author.name)}</a>
<div class="author-meta">${esc(meta)}</div>
</div>
<div class="author-url-area">${urlAreaHtml(author)}</div>`;
return row;
}
function urlAreaHtml(author) {
if (author.url) {
return `<div class="url-display" title="${esc(author.url)}"><a href="${esc(author.url)}" target="_blank" rel="noopener noreferrer">${esc(hostOf(author.url))}</a></div>
<button class="btn-visit" onclick="visitAuthor(this)" title="${esc(author.url)}">↗ Visit</button>
<button class="btn-edit" onclick="startEdit(this)">Edit</button>`;
}
return `<span class="no-url-label"></span>
<button class="btn-edit" onclick="startEdit(this)">+ URL</button>`;
}
function visitAuthor(btn) {
const row = btn.closest('.author-row');
const url = row.dataset.url;
if (url) window.open(url, '_blank', 'noopener,noreferrer');
}
function startEdit(btn) {
const row = btn.closest('.author-row');
const currentUrl = row.dataset.url || '';
const area = row.querySelector('.author-url-area');
area.innerHTML = `
<div class="url-edit-form">
<input class="url-input" type="url" value="${esc(currentUrl)}" placeholder="https://…"/>
<button class="btn-save" onclick="saveUrl(this)">Save</button>
<button class="btn-cancel" onclick="cancelEdit(this)">Cancel</button>
</div>`;
const input = area.querySelector('.url-input');
input.focus();
input.select();
input.addEventListener('keydown', e => {
if (e.key === 'Enter') saveUrl(input.nextElementSibling);
if (e.key === 'Escape') cancelEdit(input.nextElementSibling.nextElementSibling);
});
}
function cancelEdit(btn) {
const row = btn.closest('.author-row');
const name = row.dataset.name;
const author = allAuthors.find(a => a.name === name);
if (!author) return;
row.querySelector('.author-url-area').innerHTML = urlAreaHtml(author);
}
async function saveUrl(btn) {
const row = btn.closest('.author-row');
const name = row.dataset.name;
const input = row.querySelector('.url-input');
const url = (input ? input.value : '').trim();
btn.disabled = true;
try {
const resp = await fetch('/api/following/' + encodeURIComponent(name), {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ url }),
});
if (!resp.ok) throw new Error('Failed');
} catch (_) {
alert('Failed to save URL.');
if (btn) btn.disabled = false;
return;
}
const author = allAuthors.find(a => a.name === name);
if (author) author.url = url || null;
row.dataset.url = url;
row.className = 'author-row' + (url ? ' has-url' : '');
row.querySelector('.author-url-area').innerHTML = urlAreaHtml(author || { url: url || null });
updateCounts();
if (currentFilter === 'following' && !url) {
row.remove();
const list = document.querySelector('#author-list .author-list');
if (list && !list.children.length) {
document.getElementById('author-list').innerHTML =
'<div class="empty">No authors followed yet. Switch to "All Authors" to add URLs.</div>';
}
}
}
loadAuthors();
</script>
</body>
</html>

View File

@ -227,6 +227,17 @@
border: 1px solid var(--border); border: 1px solid var(--border);
} }
.btn-outline:hover { background: var(--surface); color: var(--text); border-color: var(--text-faint); } .btn-outline:hover { background: var(--surface); color: var(--text); border-color: var(--text-faint); }
.dup-warning {
display: none; width: 100%; max-width: 620px; margin-bottom: 1.5rem;
background: rgba(200,160,58,0.08); border: 1px solid rgba(200,160,58,0.35);
border-radius: var(--radius); padding: 0.85rem 1rem;
font-family: var(--mono); font-size: 0.78rem; color: var(--warning);
line-height: 1.6;
}
.dup-warning.visible { display: block; }
.dup-warning a { color: var(--accent2); text-decoration: none; }
.dup-warning a:hover { text-decoration: underline; }
</style> </style>
</head> </head>
<body> <body>
@ -239,7 +250,7 @@
<div class="card"> <div class="card">
<div class="card-title">Book URL</div> <div class="card-title">Book URL</div>
<label for="url">Story overview page</label> <label for="url">Story overview page</label>
<input type="url" id="url" placeholder="https://..." oninput="checkUrlCredentials()"/> <input type="url" id="url" placeholder="https://..." oninput="checkUrlCredentials(); clearDupWarning()"/>
<div class="cred-status" id="cred-status"></div> <div class="cred-status" id="cred-status"></div>
<button id="load-btn" onclick="loadMeta()"> <button id="load-btn" onclick="loadMeta()">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"> <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
@ -250,6 +261,9 @@
</button> </button>
</div> </div>
<!-- Duplicate warning -->
<div class="dup-warning" id="dup-warning"></div>
<!-- Step 2: Metadata preview + cover upload + Convert --> <!-- Step 2: Metadata preview + cover upload + Convert -->
<div class="card" id="meta-card"> <div class="card" id="meta-card">
<div class="card-title">Book info</div> <div class="card-title">Book info</div>
@ -362,6 +376,7 @@
} }
renderMeta(d); renderMeta(d);
showDupWarning(d.already_exists ? d.existing_books : []);
document.getElementById('meta-card').classList.add('visible'); document.getElementById('meta-card').classList.add('visible');
// Reset cover upload // Reset cover upload
document.getElementById('cover-file').value = ''; document.getElementById('cover-file').value = '';
@ -553,6 +568,22 @@
div.scrollTop = div.scrollHeight; div.scrollTop = div.scrollHeight;
} }
function clearDupWarning() {
const el = document.getElementById('dup-warning');
el.classList.remove('visible');
el.innerHTML = '';
}
function showDupWarning(books) {
const el = document.getElementById('dup-warning');
if (!books || !books.length) { clearDupWarning(); return; }
const links = books.map(b =>
`<a href="/library/book/${encodeURIComponent(b.filename)}" target="_blank">${esc(b.title)}</a>`
).join(', ');
el.innerHTML = `⚠ This title already exists in your library: ${links}. You can still proceed with the conversion.`;
el.classList.add('visible');
}
function esc(s) { function esc(s) {
return String(s ?? '') return String(s ?? '')
.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;'); .replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');

View File

@ -78,6 +78,7 @@ All files are stored under `library/` (relative to the app working directory, ma
`GET /api/library` runs in fast-path mode by default (DB-only, no full disk rescan). `GET /api/library` runs in fast-path mode by default (DB-only, no full disk rescan).
For a forced sync: `GET /api/library?rescan=true` or `POST /library/rescan`. For a forced sync: `GET /api/library?rescan=true` or `POST /library/rescan`.
`include_file_info=true` is optional for file size/mtime enrichment. `include_file_info=true` is optional for file size/mtime enrichment.
ETag caching: response includes `ETag: "{count}-{max_updated_at_unix}"` and `Cache-Control: no-cache`. Client sends `If-None-Match`; server returns `304 Not Modified` when nothing changed.
`/api/home` returns: `/api/home` returns:
- `continue_reading` - `continue_reading`
@ -164,6 +165,18 @@ Home read sections are ordered oldest-first:
Publish flow: all chapters are run through `normalize_wysiwyg_html()`, then `build_epub()` produces an EPUB 2.0 ZIP. The file path is computed via `make_rel_path(media_type="epub", …)`. The book is inserted into the library with `needs_review=True`. The draft is deleted on success. Publish flow: all chapters are run through `normalize_wysiwyg_html()`, then `build_epub()` produces an EPUB 2.0 ZIP. The file path is computed via `make_rel_path(media_type="epub", …)`. The book is inserted into the library with `needs_review=True`. The draft is deleted on success.
### `routers/following.py`
- `GET /following` — Following page (author URL management)
- `GET /api/following` — all distinct library authors with URL (if set), book count, and last-added date
- `POST /api/following/{author_name}` — set or clear URL for an author (empty `url` removes the record)
`GET /api/following` returns one entry per non-archived author:
```json
{ "name": "Author Name", "book_count": 5, "last_added": "2026-03-27T…", "url": "https://…" }
```
URL is stored in the `authors` table (`name` unique, `url`, `created_at`, `updated_at`).
### `routers/backup.py` ### `routers/backup.py`
- `GET /backup` — backup page - `GET /backup` — backup page
- `GET /api/backup/credentials` — Dropbox settings (includes `app_key_configured` flag) - `GET /api/backup/credentials` — Dropbox settings (includes `app_key_configured` flag)
@ -254,6 +267,10 @@ Dropbox settings are managed via the web UI on `/backup`.
- `Edit EPUB` button in Book Detail is only shown for `.epub` files. - `Edit EPUB` button in Book Detail is only shown for `.epub` files.
- Backup page supports: manual run, dry-run, Dropbox root, retention count, schedule (on/off + hours), status + history. - Backup page supports: manual run, dry-run, Dropbox root, retention count, schedule (on/off + hours), status + history.
- Bookmarks: saved per book via `POST /library/bookmarks/{filename}`; shown in Library sidebar section; navigated via `?bm_ch=N&bm_scroll=F` URL params on reader page. - Bookmarks: saved per book via `POST /library/bookmarks/{filename}`; shown in Library sidebar section; navigated via `?bm_ch=N&bm_scroll=F` URL params on reader page.
- Convert page: after loading metadata, if a book with the same title+author already exists in the library, a warning banner is shown (with a link to the existing book); user can still proceed with conversion. Check is done server-side in `/preload` response (`already_exists`, `existing_books`).
- Duplicates view (`#duplicates`): groups non-archived books by `(title, author)` (case-insensitive); shows only groups with ≥ 2 copies; counter in sidebar shows total number of duplicate books. Detection is entirely client-side from the existing library data.
- Incomplete view (`#incomplete`): shows all non-archived books where `publication_status` is not `Complete` (Ongoing, Hiatus, or blank); sidebar counter included.
- Following page (`/following`): dedicated page in its own sidebar section between Library and Tools; shows all library authors with their external URL; two tabs — Following (authors with URL set) and All Authors; inline URL editing with keyboard support (Enter = save, Escape = cancel); clicking Visit opens the external URL in a new tab. Author URLs are stored in the `authors` table. Sidebar counter shows number of followed authors.
- Book Builder (`/builder`): create EPUB books from scratch; drafts stored in `builder_drafts` (JSONB chapters); contenteditable editor with toolbar (bold/italic/underline/blockquote/author-note/scene-break/normalize); autosave every 30 s + Ctrl+S; publish normalizes HTML via `normalize_wysiwyg_html()` and builds EPUB via `build_epub()`. - Book Builder (`/builder`): create EPUB books from scratch; drafts stored in `builder_drafts` (JSONB chapters); contenteditable editor with toolbar (bold/italic/underline/blockquote/author-note/scene-break/normalize); autosave every 30 s + Ctrl+S; publish normalizes HTML via `normalize_wysiwyg_html()` and builds EPUB via `build_epub()`.
--- ---
@ -275,9 +292,13 @@ Dropbox settings are managed via the web UI on `/backup`.
--- ---
## Performance Notes ## Performance Notes
- Library load is optimized for large datasets: - Library load is optimized for large datasets (1000+ books):
- `list_library_json()` uses pre-aggregation for `reading_sessions`. - `list_library_json()` uses `json_agg` in the main query to inline tags per book — eliminates a separate `SELECT * FROM book_tags` query and Python merge loop.
- `has_cached_cover` is provided directly via SQL join instead of full cache fetch. - `has_cached_cover` is provided directly via SQL join instead of full cache fetch.
- `reading_sessions` is pre-aggregated in a subquery.
- ETag on `/api/library`: cheap `COUNT + MAX(updated_at)` query before full load; `304 Not Modified` on cache hit.
- Front-end rendering uses `IntersectionObserver` to defer both cover image loading and placeholder canvas drawing until cards enter the viewport — prevents hundreds of simultaneous HTTP requests and canvas operations on initial render.
- `renderBooksGrid`, `renderDuplicatesView`, `renderSeriesDetail` all use a single DOM pass: cover `<img>` and `<canvas>` are set up via `card.querySelector` immediately after `innerHTML` is set, eliminating a second full iteration with `document.getElementById` calls.
- Additional migration indexes: - Additional migration indexes:
- `idx_library_sort_coalesce` - `idx_library_sort_coalesce`
- `idx_library_needs_review` - `idx_library_needs_review`

View File

@ -3,6 +3,27 @@
This file tracks changes on the `develop` line. This file tracks changes on the `develop` line.
`changelog.md` can later be used for release summaries. `changelog.md` can later be used for release summaries.
## 2026-03-28 (2)
- Performance: library page now loads instantly for large collections (1000+ books)
- `IntersectionObserver` defers both cover image loading and placeholder canvas drawing until cards enter the viewport — eliminates hundreds of upfront canvas ops that blocked the initial render
- `ETag` caching on `/library/list`: server returns `304 Not Modified` when nothing changed, client skips JSON parse and re-download
- Single DOM pass in `renderBooksGrid`, `renderDuplicatesView`, `renderSeriesDetail`: canvas and img set up via `card.querySelector` immediately after `innerHTML`, removing a second iteration with `document.getElementById` per card
- `book_tags` joined via `json_agg` in the main `list_library_json()` query, eliminating a separate `SELECT * FROM book_tags` query and Python merge loop
- `loadLibrary` now shows an error message instead of staying stuck on "Loading…" when the fetch or render fails
## 2026-03-28 (1)
- Added Following page (`/following`): track external author URLs outside Library and Tools
- New `authors` table: `name` (unique), `url`, `created_at`, `updated_at`
- New `routers/following.py`: `GET /following` page, `GET /api/following` (all authors + URL + book count + last added), `POST /api/following/{name}` (set/clear URL)
- Sidebar: new Following section between Library and Tools; counter shows number of followed authors
- Following page: two tabs — Following (authors with URL) and All Authors; inline URL editing with Enter/Escape keyboard support; Visit button opens external URL in a new tab; author name links to library author view
- Added Incomplete view to Library (`#incomplete`): shows all non-archived books where `publication_status ≠ Complete`; sidebar counter included; entry placed after New in the Library section
## 2026-03-27 (1)
- Convert page: duplicate warning shown after loading metadata when a book with the same title+author already exists in the library; warning includes a link to the existing book; user can still proceed with conversion
- Library: added Duplicates section to sidebar (between Rated and Statistics); counter shows total number of books that are part of a duplicate group (same title+author, case-insensitive); Duplicates view groups books by title+author with a subheading per group
- Fixed Duplicates view not loading covers: card renderer now uses the same canvas + two-pass img/`makePlaceholderCover` pattern as `renderBooksGrid`
## 2026-03-26 (2) ## 2026-03-26 (2)
- Fixed Book Builder page showing white background: `library.css` added to `builder.html` to load `:root` CSS variables and dark `body` background; all CSS variable references in `builder.css` aligned with library theme names (`--text`, `--surface`, `--surface2`, `--text-dim`, `--border`, `--accent`, `--sidebar`) - Fixed Book Builder page showing white background: `library.css` added to `builder.html` to load `:root` CSS variables and dark `body` background; all CSS variable references in `builder.css` aligned with library theme names (`--text`, `--surface`, `--surface2`, `--text-dim`, `--border`, `--accent`, `--sidebar`)