Merge branch v20260327-01 into main
This commit is contained in:
commit
f4ac7a7662
@ -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("/")
|
||||||
|
|||||||
@ -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()
|
||||||
|
|||||||
@ -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",
|
||||||
]
|
]
|
||||||
|
|||||||
@ -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,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|||||||
68
containers/novela/routers/following.py
Normal file
68
containers/novela/routers/following.py
Normal 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}
|
||||||
@ -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,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -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)
|
||||||
|
|||||||
@ -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);
|
||||||
|
|||||||
@ -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 {
|
||||||
|
const headers = {};
|
||||||
|
if (_libraryETag) headers['If-None-Match'] = _libraryETag;
|
||||||
|
|
||||||
|
const resp = await fetch('/library/list', { headers });
|
||||||
|
|
||||||
|
if (resp.status === 304) {
|
||||||
|
// Data unchanged — skip JSON parse, just re-render current view
|
||||||
|
updateCounts();
|
||||||
|
renderGrid();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!resp.ok) {
|
||||||
|
document.getElementById('grid-container').innerHTML =
|
||||||
|
`<div class="empty">Failed to load library (HTTP ${resp.status}). Check server logs.</div>`;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const etag = resp.headers.get('ETag');
|
||||||
|
if (etag) _libraryETag = etag;
|
||||||
|
|
||||||
allBooks = await resp.json();
|
allBooks = await resp.json();
|
||||||
updateCounts();
|
updateCounts();
|
||||||
renderGrid();
|
renderGrid();
|
||||||
return true;
|
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();
|
||||||
@ -903,6 +946,7 @@ function renderBooksGrid(books) {
|
|||||||
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,21 +1263,10 @@ 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>`;
|
||||||
wrapper.appendChild(bookCard);
|
|
||||||
}
|
|
||||||
|
|
||||||
grid.appendChild(wrapper);
|
// Single pass: set up cover using local querySelector
|
||||||
});
|
const wrap = bookCard.querySelector('.cover-wrap');
|
||||||
|
const canvas = bookCard.querySelector('.cover-canvas');
|
||||||
container.innerHTML = '';
|
|
||||||
container.appendChild(grid);
|
|
||||||
|
|
||||||
slots.filter(s => !s.missing).forEach(b => {
|
|
||||||
const author = bookAuthor(b);
|
|
||||||
const title = bookTitle(b);
|
|
||||||
const canvas = document.getElementById(`canvas-${cssId(b.filename)}`);
|
|
||||||
const wrap = document.getElementById(`wrap-${cssId(b.filename)}`);
|
|
||||||
if (!canvas) return;
|
|
||||||
if (b.has_cover) {
|
if (b.has_cover) {
|
||||||
const img = document.createElement('img');
|
const img = document.createElement('img');
|
||||||
img.style.cssText = 'position:absolute;inset:0;width:100%;height:100%;object-fit:cover';
|
img.style.cssText = 'position:absolute;inset:0;width:100%;height:100%;object-fit:cover';
|
||||||
@ -1229,7 +1277,15 @@ function renderSeriesDetail(seriesName) {
|
|||||||
wrap.insertBefore(img, wrap.firstChild);
|
wrap.insertBefore(img, wrap.firstChild);
|
||||||
}
|
}
|
||||||
requestAnimationFrame(() => makePlaceholderCover(canvas, title, author));
|
requestAnimationFrame(() => makePlaceholderCover(canvas, title, author));
|
||||||
|
|
||||||
|
wrapper.appendChild(bookCard);
|
||||||
|
}
|
||||||
|
|
||||||
|
grid.appendChild(wrapper);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
container.innerHTML = '';
|
||||||
|
container.appendChild(grid);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── 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);
|
||||||
|
|||||||
@ -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>
|
||||||
@ -236,6 +274,16 @@
|
|||||||
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>
|
||||||
|
|||||||
324
containers/novela/templates/following.html
Normal file
324
containers/novela/templates/following.html
Normal 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,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
||||||
|
}
|
||||||
|
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>
|
||||||
@ -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, '&').replace(/</g, '<').replace(/>/g, '>');
|
.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
||||||
|
|||||||
@ -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`
|
||||||
|
|||||||
@ -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`)
|
||||||
|
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user