diff --git a/containers/novela/main.py b/containers/novela/main.py index fdf7068..e57425e 100644 --- a/containers/novela/main.py +++ b/containers/novela/main.py @@ -11,6 +11,7 @@ from routers import ( backup_router, builder_router, editor_router, + following_router, grabber_router, library_router, reader_router, @@ -40,6 +41,7 @@ app.include_router(grabber_router) app.include_router(settings_router) app.include_router(backup_router) app.include_router(builder_router) +app.include_router(following_router) @app.get("/") diff --git a/containers/novela/migrations.py b/containers/novela/migrations.py index 40919aa..3c517a4 100644 --- a/containers/novela/migrations.py +++ b/containers/novela/migrations.py @@ -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: migrate_create_library() migrate_create_book_tags() @@ -294,3 +308,4 @@ def run_migrations() -> None: migrate_create_bookmarks() migrate_series_suffix() migrate_create_builder_drafts() + migrate_create_authors() diff --git a/containers/novela/routers/__init__.py b/containers/novela/routers/__init__.py index 3d13cb3..9715803 100644 --- a/containers/novela/routers/__init__.py +++ b/containers/novela/routers/__init__.py @@ -1,6 +1,7 @@ from routers.backup import router as backup_router from routers.builder import router as builder_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.library import router as library_router from routers.reader import router as reader_router @@ -14,4 +15,5 @@ __all__ = [ "backup_router", "settings_router", "builder_router", + "following_router", ] diff --git a/containers/novela/routers/common.py b/containers/novela/routers/common.py index ab5c36f..bf7e3cb 100644 --- a/containers/novela/routers/common.py +++ b/containers/novela/routers/common.py @@ -407,7 +407,10 @@ def list_library_json() -> list[dict]: rs.last_read, (cc.filename IS NOT NULL) AS has_cached_cover, 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 LEFT JOIN reading_progress rp ON rp.filename = l.filename LEFT JOIN ( @@ -416,16 +419,17 @@ def list_library_json() -> list[dict]: GROUP BY filename ) rs ON rs.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, '') """ ) 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 = [] for r in rows: @@ -451,7 +455,7 @@ def list_library_json() -> list[dict]: "page": r[15], "read_count": r[16] or 0, "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, } ) diff --git a/containers/novela/routers/following.py b/containers/novela/routers/following.py new file mode 100644 index 0000000..608ae00 --- /dev/null +++ b/containers/novela/routers/following.py @@ -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} diff --git a/containers/novela/routers/grabber.py b/containers/novela/routers/grabber.py index 43396a8..ca54113 100644 --- a/containers/novela/routers/grabber.py +++ b/containers/novela/routers/grabber.py @@ -216,9 +216,27 @@ async def preload(request: Request): book = await scraper.fetch_book_info(client, url) series = book.get("series", "") 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 { - "title": book.get("title", ""), - "author": book.get("author", ""), + "title": title, + "author": author, "publisher": book.get("publisher", ""), "series": 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", ""), "updated_date": book.get("updated_date", ""), "publication_status": book.get("publication_status", ""), + "already_exists": bool(existing_books), + "existing_books": existing_books, } diff --git a/containers/novela/routers/library.py b/containers/novela/routers/library.py index 3c3c8b9..b24ca3f 100644 --- a/containers/novela/routers/library.py +++ b/containers/novela/routers/library.py @@ -4,7 +4,7 @@ from datetime import datetime, timezone from pathlib import Path 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 PIL import UnidentifiedImageError @@ -69,19 +69,33 @@ async def library_page(request: Request): @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. # Use /library/rescan (or ?rescan=true) when a full sync is needed. if rescan: _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() if include_file_info: for b in books: p = resolve_library_path(b["filename"]) if p and p.exists(): b.update(relative_file_info(p)) - return books + return JSONResponse(content=books, headers={"ETag": etag, "Cache-Control": "no-cache"}) @router.post("/library/rescan") @@ -719,5 +733,5 @@ async def api_stats(): @router.get("/library/list") -async def library_list_compat(): - return await api_library() +async def library_list_compat(request: Request): + return await api_library(request) diff --git a/containers/novela/static/library.css b/containers/novela/static/library.css index 826ce23..67662aa 100644 --- a/containers/novela/static/library.css +++ b/containers/novela/static/library.css @@ -59,6 +59,18 @@ html, body { 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 { border: 1px dashed var(--border); background: rgba(34, 31, 27, 0.45); diff --git a/containers/novela/static/library.js b/containers/novela/static/library.js index 5b936a8..d789294 100644 --- a/containers/novela/static/library.js +++ b/containers/novela/static/library.js @@ -107,12 +107,41 @@ function truncate(s, n) { return s.length > n ? s.slice(0, n - 1) + '…' : s; } // ── Data loading ─────────────────────────────────────────────────────────── +let _libraryETag = null; + async function loadLibrary() { - const resp = await fetch('/library/list'); - allBooks = await resp.json(); - updateCounts(); - renderGrid(); - return true; + 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 = + `
Failed to load library (HTTP ${resp.status}). Check server logs.
`; + 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 = + `
Failed to load library: ${String(err)}. Check browser console.
`; + return false; + } } function activeBooks() { return allBooks.filter(b => !b.archived); } @@ -138,6 +167,10 @@ function updateCounts() { const ratedCount = active.filter(b => b.rating > 0).length; const ratedEl = document.getElementById('count-rated'); if (ratedEl) ratedEl.textContent = ratedCount || ''; + const dupGroups = _duplicateGroups(active); + const dupCount = dupGroups.reduce((s, g) => s + g.books.length, 0); + const dupEl = document.getElementById('count-duplicates'); + if (dupEl) dupEl.textContent = dupCount || ''; } function _filenameBase(filename) { @@ -184,6 +217,8 @@ function _viewUrl(view, param) { if (view === 'archived') return '/library#archived'; if (view === 'bookmarks') return '/library#bookmarks'; if (view === 'rated') return '/library#rated'; + if (view === 'duplicates') return '/library#duplicates'; + if (view === 'incomplete') return '/library#incomplete'; if (view === 'new') return '/library#new'; if (view === 'genre') return '/library#genre/' + encodeURIComponent(param || ''); return '/library'; @@ -199,7 +234,7 @@ function _applyView(view, param) { 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); if (el) el.classList.remove('active'); }); @@ -209,9 +244,11 @@ function _applyView(view, param) { 'authors': 'nav-authors', 'author-detail': 'nav-authors', 'publishers': 'nav-publishers', 'publisher-detail': 'nav-publishers', 'new': 'nav-new', + 'incomplete': 'nav-incomplete', 'archived': 'nav-archived', 'bookmarks': 'nav-bookmarks', 'rated': 'nav-rated', + 'duplicates': 'nav-duplicates', }; const el = document.getElementById(activeMap[view]); if (el) el.classList.add('active'); @@ -229,6 +266,8 @@ function _applyView(view, param) { view === 'archived' ? 'Archived' : view === 'bookmarks' ? 'Bookmarks' : view === 'rated' ? 'Rated' : + view === 'duplicates' ? 'Duplicates' : + view === 'incomplete' ? 'Incomplete' : view === 'genre' ? `Genre: ${param || ''}` : view === 'search' ? `Search: "${param || ''}"` : ''; @@ -279,6 +318,8 @@ function renderGrid() { else if (currentView === 'search') renderSearchResults(currentParam); else if (currentView === 'bookmarks') renderBookmarksView(); 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) ───────────────────────────── @@ -893,6 +934,8 @@ async function confirmBulkDelete() { // ── Book grid (All / WTR / Author detail) ───────────────────────────────── +let _coverObserver = null; + function renderBooksGrid(books) { const container = document.getElementById('grid-container'); 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 === 'archived' ? 'No archived books. Archive a book from its detail page.' : currentView === 'new' ? 'No newly imported books waiting for metadata review.' : - currentView === 'rated' ? 'No rated books yet. Rate a book from its detail page.' : + currentView === '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 === 'search' ? `No results for "${esc(currentParam || '')}".` : 'No books yet. Import EPUB, PDF or CBR/CBZ to get started.' @@ -910,6 +954,25 @@ function renderBooksGrid(books) { return; } + // Reset lazy-load observer for this render pass. + // Handles both cover elements (data-src) and placeholder 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'); grid.className = 'cover-grid'; @@ -944,8 +1007,8 @@ function renderBooksGrid(books) { : ''; card.innerHTML = ` -
- +
+
`; 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); }); container.innerHTML = ''; container.appendChild(grid); - - books.forEach(b => { - const author = bookAuthor(b); - const title = bookTitle(b); - const wrap = document.getElementById(`wrap-${cssId(b.filename)}`); - const canvas = document.getElementById(`canvas-${cssId(b.filename)}`); - if (b.has_cover) { - const img = document.createElement('img'); - img.className = 'cover-img'; - img.style.cssText = 'position:absolute;inset:0;width:100%;height:100%;object-fit:cover'; - img.src = `/library/cover-cached/${encodeURIComponent(b.filename)}`; - img.alt = title; - if (b.has_cached_cover) { - canvas.style.display = 'none'; - } - img.onload = () => { canvas.style.display = 'none'; }; - img.onerror = () => { - canvas.style.display = 'block'; - makePlaceholderCover(canvas, title, author); - }; - wrap.insertBefore(img, wrap.firstChild); - } - if (!b.has_cover || !b.has_cached_cover) { - requestAnimationFrame(() => makePlaceholderCover(canvas, title, author)); - } - }); } // ── Series grid ──────────────────────────────────────────────────────────── @@ -1193,8 +1252,8 @@ function renderSeriesDetail(seriesName) { bookCard.style.cursor = 'pointer'; bookCard.onclick = () => { location.href = `/library/book/${encodeURIComponent(b.filename)}`; }; bookCard.innerHTML = ` -
- +
+ ${statusBadge} ${b.read_count > 0 ? `
${b.read_count}\u00d7
` : ''} ${b.progress > 0 ? `
` : ''} @@ -1204,6 +1263,21 @@ function renderSeriesDetail(seriesName) {
${esc(title)}
${esc(author)}
`; + + // 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); } @@ -1212,24 +1286,6 @@ function renderSeriesDetail(seriesName) { container.innerHTML = ''; container.appendChild(grid); - - slots.filter(s => !s.missing).forEach(b => { - const author = bookAuthor(b); - const title = bookTitle(b); - const canvas = document.getElementById(`canvas-${cssId(b.filename)}`); - const wrap = document.getElementById(`wrap-${cssId(b.filename)}`); - if (!canvas) return; - if (b.has_cover) { - const img = document.createElement('img'); - img.style.cssText = 'position:absolute;inset:0;width:100%;height:100%;object-fit:cover'; - img.src = `/library/cover/${encodeURIComponent(b.filename)}`; - img.alt = title; - img.onload = () => { canvas.style.display = 'none'; }; - img.onerror = () => { requestAnimationFrame(() => makePlaceholderCover(canvas, title, author)); }; - wrap.insertBefore(img, wrap.firstChild); - } - requestAnimationFrame(() => makePlaceholderCover(canvas, title, author)); - }); } // ── Authors list ─────────────────────────────────────────────────────────── @@ -1473,6 +1529,115 @@ function renderRatedView() { 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 = '
No duplicate books found.
'; + 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 = `
+ +
`; + } else if (st === 'ongoing') { + statusBadge = `
+ +
`; + } else if (st === 'hiatus') { + statusBadge = `
+ +
`; + } + + const starClass = b.want_to_read ? 'btn-star starred' : 'btn-star'; + + card.innerHTML = ` +
+ + + ${statusBadge} + ${b.read_count > 0 ? `
${b.read_count}\u00d7
` : ''} + ${b.progress > 0 ? `
` : ''} +
+ ${starsHtml(b.filename, b.rating)} +
+
${esc(title)}
+
${esc(author)}
+
`; + 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 ────────────────────────────────────────────────────────── function renderAuthorDetail(authorName) { @@ -1702,7 +1867,7 @@ document.getElementById('search-input').addEventListener('input', function() { if (q) { currentView = 'search'; 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); if (el) el.classList.remove('active'); }); @@ -1764,6 +1929,8 @@ loadLibrary().then(() => { else if (hash === 'new') view = 'new'; else if (hash === 'bookmarks') view = 'bookmarks'; else if (hash === 'rated') view = 'rated'; + else if (hash === 'duplicates') view = 'duplicates'; + else if (hash === 'incomplete') view = 'incomplete'; else if (hash.startsWith('genre/')) { view = 'genre'; param = decodeURIComponent(hash.slice(6)); } history.replaceState({ view, param }, '', _viewUrl(view, param)); _applyView(view, param); diff --git a/containers/novela/templates/_sidebar.html b/containers/novela/templates/_sidebar.html index 47d6c1e..34ad2e4 100644 --- a/containers/novela/templates/_sidebar.html +++ b/containers/novela/templates/_sidebar.html @@ -60,6 +60,16 @@ +
  • + + + + + Incomplete + + +
  • @@ -129,6 +139,16 @@
  • +
  • + + + + + Duplicates + + +
  • @@ -143,6 +163,24 @@ + + + + +