Merge branch v20260331-01 into main

This commit is contained in:
Ivo Oskamp 2026-04-03 15:15:15 +02:00
commit d8d30fb00d
42 changed files with 929 additions and 657 deletions

View File

@ -3,14 +3,14 @@ set -euo pipefail
# ============================================================================
# build-and-push.sh
# Location: repo root (e.g. /docker/develop/novela)
# Location: repo root
#
# Purpose:
# - Automatic version bump:
# 1 = patch, 2 = minor, 3 = major, t = test
# - Test builds: only update :dev (no commit/tag)
# - Release builds: update version.txt, commit, tag, push (to the current branch)
# - Build & push Docker images for each service under ./containers/*
# - Build & push Docker images for each service under ./compose/*
# - Preflight checks: Docker daemon up, logged in to registry, valid names/tags
# - Summary: show all images + tags built and pushed
# - Branch visibility:
@ -120,7 +120,7 @@ if [[ ! -d ".git" ]]; then
fi
if [[ ! -d "$COMPOSE_DIR" ]]; then
echo "[ERROR] '$COMPOSE_DIR' directory missing. Expected ./containers/<service>/ with a Dockerfile."
echo "[ERROR] '$COMPOSE_DIR' directory missing. Expected ./compose/<service>/ with a Dockerfile."
exit 1
fi

View File

@ -0,0 +1,134 @@
"""
Changelog data for Novela
"""
CHANGELOG = [
{
"version": "v0.1.1",
"date": "2026-03-31",
"summary": "Bug fixes, volume-aware duplicate detection, shared code cleanup, and a new Changelog page.",
"sections": [
{
"title": "Bug fixes",
"type": "bugfix",
"changes": [
"Duplicates view crashed on load due to a TypeError (g.books.length was undefined); counter was stale and the view never rendered",
"Duplicate detection was too aggressive: different volumes of the same series (same title + author, different volume) were incorrectly grouped as duplicates — now keyed on title + author + volume",
"Grabber preload: same volume-aware fix — only flags a duplicate when title, author, and volume all match; falls back to title + author when no volume is known",
"Bulk Import duplicate check: different volumes of the same series are no longer flagged as duplicates",
],
},
{
"title": "Improvements",
"type": "improvement",
"changes": [
"Search changed from search-as-you-type (250 ms debounce) to Enter-to-search — prevents the iPad keyboard from locking up on large collections",
"CBR reader: archive format now detected via magic bytes instead of file extension — .cbr files that are actually ZIP or 7-zip archives open correctly; added 7-zip support via py7zr",
"Docker: replaced unrar-free with proprietary unrar (RARLAB v6.2.6) — fixes failures on RAR archives using newer compression methods",
],
},
{
"title": "New feature",
"type": "feature",
"changes": [
"Changelog page (/changelog): structured release history with version, date, and categorised change lists",
],
},
{
"title": "Code quality",
"type": "improvement",
"changes": [
"Shared CSS (theme.css): single :root block with all global CSS custom properties; loaded on every page — no more duplicate inline :root blocks across templates",
"Shared JS (books.js): book helpers (bookTitle, bookAuthor, bookGenres, bookSubgenres, bookPlainTags, filterBooks) and search input wiring extracted into one shared file",
"Shared JS (conversion.js): SSE/EventSource logic (connectConversionStream, addLog) extracted from Convert and Grabber pages into one shared file",
],
},
],
},
{
"version": "v0.1.0",
"date": "2026-03-29",
"summary": "First release of Novela: a self-hosted personal library for EPUB, PDF, CBR, and CBZ files.",
"sections": [
{
"title": "Library",
"type": "feature",
"changes": [
"Grid and List view for all books and New books, with column visibility filter and persistent view mode",
"Sidebar navigation: All books, Want to Read, New, Incomplete, Series, Authors, Publishers, Archived, Bookmarks, Rated, Duplicates, Statistics — all with live counters",
"15 star ratings stored in the database and written back to EPUB OPF and CBZ ComicInfo.xml",
"Publication status: Complete, Ongoing, Temporary Hold, Long-Term Hold",
"Status and want-to-read badges on grid covers, always readable regardless of cover colour",
"Duplicate detection: groups books by title and author with a sidebar counter",
"Incomplete view: all non-archived books where publication status is not Complete",
"Rated view: non-archived books with a star rating, sorted by rating",
"Bulk delete in All books List view with multi-select and Shift+click range selection",
"Disk usage warning in sidebar (amber ≥ 85%, red ≥ 95% or low free space)",
"Autocomplete for Author, Publisher, and Series in the book edit panel",
"Series volume suffix support (e.g. 21a, 21b) and volume 0 for prequels and specials",
"Cover upload for EPUB books with cover cache for fast subsequent loads",
],
},
{
"title": "Reader",
"type": "feature",
"changes": [
"EPUB reader with chapter navigation, scroll progress, and bookmarks",
"PDF reader with page-image rendering and page navigation",
"CBR/CBZ reader with page-image rendering; format detection via magic bytes (supports ZIP, RAR, and 7-zip archives)",
"Reader text colour: 5 warm-tone presets, persisted per browser",
"Content width slider (30100 vw), persisted per browser",
"Bookmarks: save position with optional note; navigate back via sidebar or bookmark list",
],
},
{
"title": "Import & Convert",
"type": "feature",
"changes": [
"Single-file import: drag-and-drop or file picker for EPUB, PDF, CBR, CBZ",
"Bulk Import: batch import with %placeholder% filename pattern parsing, shared metadata, live preview table, and duplicate detection",
"Convert: scrape web fiction and convert to EPUB; warns if title and author already exist in the library",
"Grabber with credentials manager for site-specific login",
],
},
{
"title": "Book Builder",
"type": "feature",
"changes": [
"Create EPUB books from scratch via a WYSIWYG editor",
"Chapters with contenteditable editing; toolbar: bold, italic, underline, blockquote, author note, scene break, normalize",
"Autosave every 30 seconds and Ctrl+S; publish produces a standards-compliant EPUB 2.0 added directly to the library",
],
},
{
"title": "Following",
"type": "feature",
"changes": [
"Track external author URLs on the Following page",
"Two tabs: Following (authors with URL set) and All Authors",
"Inline URL editing with Enter/Escape support; Visit opens URL in a new tab",
"Sidebar counter shows number of followed authors",
],
},
{
"title": "Backup",
"type": "feature",
"changes": [
"Dropbox backup with versioned snapshots and object-store deduplication",
"OAuth2 refresh token flow (does not expire); legacy access token supported as fallback",
"Configurable backup root, snapshot retention, and scheduled interval",
"Live backup progress in sidebar (file count and phase); backup status indicator with time-ago",
"PostgreSQL dump included in each backup run",
],
},
{
"title": "Performance",
"type": "improvement",
"changes": [
"Library loads instantly for large collections: ETag 304 Not Modified, lazy cover loading via IntersectionObserver, single DOM pass rendering, SQL tag aggregation",
"Fast-path API (database-only); full disk rescan only on demand",
],
},
],
},
]

View File

@ -11,6 +11,7 @@ from routers import (
backup_router,
builder_router,
bulk_import_router,
changelog_router,
editor_router,
following_router,
grabber_router,
@ -44,6 +45,7 @@ app.include_router(backup_router)
app.include_router(builder_router)
app.include_router(bulk_import_router)
app.include_router(following_router)
app.include_router(changelog_router)
@app.get("/")

View File

@ -1,6 +1,7 @@
from routers.backup import router as backup_router
from routers.builder import router as builder_router
from routers.bulk_import import router as bulk_import_router
from routers.changelog import router as changelog_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
@ -18,4 +19,5 @@ __all__ = [
"builder_router",
"bulk_import_router",
"following_router",
"changelog_router",
]

View File

@ -0,0 +1,17 @@
from fastapi import APIRouter, Request
from fastapi.responses import HTMLResponse
from fastapi.templating import Jinja2Templates
from changelog import CHANGELOG
router = APIRouter()
templates = Jinja2Templates(directory="templates")
@router.get("/changelog", response_class=HTMLResponse)
async def changelog_page(request: Request):
return templates.TemplateResponse("changelog.html", {
"request": request,
"active": "changelog",
"changelog": CHANGELOG,
})

View File

@ -224,14 +224,24 @@ async def preload(request: Request):
with get_db_conn() as conn:
with conn.cursor() as cur:
cur.execute(
"""SELECT filename, title, author FROM library
"""SELECT filename, title, author, series_index FROM library
WHERE LOWER(TRIM(title)) = LOWER(TRIM(%s))
AND LOWER(TRIM(author)) = LOWER(TRIM(%s))""",
(title, author),
)
rows = cur.fetchall()
if hint:
# Volume known: only a duplicate when title+author+volume all match
existing_books = [
{"filename": r[0], "title": r[1] or "", "author": r[2] or ""}
for r in cur.fetchall()
for r in rows if r[3] == hint
]
else:
# No volume: duplicate if any title+author match exists
existing_books = [
{"filename": r[0], "title": r[1] or "", "author": r[2] or ""}
for r in rows
]
return {

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.8 KiB

View File

@ -1,13 +1,5 @@
/* ── Novela — Book detail page styles ─────────────────────────────────── */
: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); }
@ -126,7 +118,7 @@ a.tag-pill:hover { color: var(--accent2); border-color: var(--accent); }
.status-complete { background: rgba(107,170,107,0.12); color: #6baa6b; border: 1px solid rgba(107,170,107,0.25); }
.status-ongoing { background: rgba(74,144,184,0.12); color: #4a90b8; border: 1px solid rgba(74,144,184,0.25); }
.status-temporary-hold { background: rgba(200,160,58,0.12); color: #c8a03a; border: 1px solid rgba(200,160,58,0.25); }
.status-long-term-hold { background: rgba(200,120,58,0.12); color: #c8783a; border: 1px solid rgba(200,120,58,0.25); }
.status-long-term-hold { background: rgba(255,162,14,0.12); color: #ffa20e; border: 1px solid rgba(255,162,14,0.25); }
/* Progress */
.progress-section { margin-bottom: 1.25rem; }

View File

@ -1,5 +1,5 @@
/* ── Novela — Book detail page script ─────────────────────────────────── */
/* Requires: BOOK global defined inline before this script is loaded */
/* Requires: books.js loaded first; BOOK global defined inline */
const { filename, title, author } = BOOK;
@ -8,43 +8,6 @@ const { filename, title, author } = BOOK;
const canvas = document.getElementById('cover-canvas');
canvas.width = 180;
canvas.height = 270;
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 makePlaceholderCover(cv, ttl, auth) {
const w = cv.width || 180, h = cv.height || 270;
const ctx = cv.getContext('2d');
const [bg, fg] = COVER_PALETTES[strHash(ttl) % COVER_PALETTES.length];
ctx.fillStyle = bg; ctx.fillRect(0, 0, w, h);
ctx.fillStyle = fg; ctx.globalAlpha = 0.15; ctx.fillRect(0, 0, w, h * 0.08); ctx.globalAlpha = 1;
ctx.fillStyle = fg; ctx.fillRect(w * 0.12, h * 0.12, w * 0.04, h * 0.55);
ctx.fillStyle = '#e8e2d9';
ctx.font = `bold ${Math.round(w * 0.105)}px 'Libre Baskerville', Georgia, serif`;
ctx.textAlign = 'center';
const words = ttl.split(' '); let line = '', lines = [];
for (const word of words) {
const test = line ? line + ' ' + word : word;
if (ctx.measureText(test).width > w * 0.72 && line) { lines.push(line); line = word; }
else line = test;
}
if (line) lines.push(line);
lines = lines.slice(0, 4);
const lineH = Math.round(w * 0.12);
const startY = h * 0.28 - ((lines.length - 1) * lineH) / 2;
lines.forEach((l, i) => ctx.fillText(l, w * 0.55, startY + i * lineH));
ctx.fillStyle = fg; ctx.font = `${Math.round(w * 0.075)}px 'DM Mono', monospace`;
ctx.globalAlpha = 0.85;
const a = auth.length > 18 ? auth.slice(0, 17) + '…' : auth;
ctx.fillText(a, w * 0.55, h * 0.86);
ctx.globalAlpha = 1;
}
requestAnimationFrame(() => makePlaceholderCover(canvas, title, author));
if (BOOK.has_cover) {
const img = document.getElementById('cover-img');

View File

@ -0,0 +1,127 @@
// ── Novela — shared utilities ────────────────────────────────────────────────
// HTML-escape a string for safe insertion into markup.
function esc(s) {
return String(s ?? '').replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}
// ── Placeholder cover generation ─────────────────────────────────────────────
function strHash(s) {
let h = 0;
for (let i = 0; i < s.length; i++) h = (Math.imul(31, h) + s.charCodeAt(i)) | 0;
return Math.abs(h);
}
const COVER_PALETTES = [
['#1a2a3a', '#4a8caa'],
['#2a1a1a', '#aa4a4a'],
['#1a2a1a', '#4aaa6a'],
['#2a1a2a', '#8a4aaa'],
['#2a2a1a', '#aaa04a'],
['#1a2a2a', '#4aaa9a'],
['#2a1a14', '#ffa20e'],
['#141a2a', '#5a78c8'],
];
function wrapText(ctx, text, x, y, maxW, lineH) {
const words = text.split(' ');
let line = '';
let lines = [];
for (const word of words) {
const test = line ? line + ' ' + word : word;
if (ctx.measureText(test).width > maxW && line) { lines.push(line); line = word; }
else line = test;
}
if (line) lines.push(line);
lines = lines.slice(0, 4);
const startY = y - ((lines.length - 1) * lineH) / 2;
lines.forEach((l, i) => ctx.fillText(l, x, startY + i * lineH));
}
function truncate(s, n) { return s.length > n ? s.slice(0, n - 1) + '…' : s; }
function makePlaceholderCover(canvas, title, author) {
const w = canvas.width = canvas.offsetWidth || 150;
const h = canvas.height = canvas.offsetHeight || 225;
const ctx = canvas.getContext('2d');
const [bg, fg] = COVER_PALETTES[strHash(title) % COVER_PALETTES.length];
ctx.fillStyle = bg;
ctx.fillRect(0, 0, w, h);
ctx.fillStyle = fg;
ctx.globalAlpha = 0.15;
ctx.fillRect(0, 0, w, h * 0.08);
ctx.globalAlpha = 1;
ctx.fillStyle = fg;
ctx.fillRect(w * 0.12, h * 0.12, w * 0.04, h * 0.55);
ctx.fillStyle = '#e8e2d9';
ctx.font = `bold ${Math.round(w * 0.105)}px 'Libre Baskerville', Georgia, serif`;
ctx.textAlign = 'center';
wrapText(ctx, title, w * 0.55, h * 0.28, w * 0.72, Math.round(w * 0.12));
ctx.fillStyle = fg;
ctx.font = `${Math.round(w * 0.075)}px 'DM Mono', monospace`;
ctx.globalAlpha = 0.85;
ctx.fillText(truncate(author, 18), w * 0.55, h * 0.86);
ctx.globalAlpha = 1;
}
// ── Shared book helpers ───────────────────────────────────────────────────────
function _filenameBase(filename) {
const leaf = String(filename || '').split('/').pop() || '';
return leaf.replace(/\.[^.]+$/, '');
}
function bookTitle(b) {
return b.title || (_filenameBase(b.filename).split('-')[2] ?? '').replace(/_/g, ' ');
}
function bookAuthor(b) {
if (b.author) return b.author;
return (_filenameBase(b.filename).split('-')[1] ?? '').replace(/_/g, ' ');
}
function tagValuesByType(b, type) {
return (b.tags || []).filter(t => t && t.tag_type === type && t.tag).map(t => t.tag);
}
function bookGenres(b) {
const explicit = tagValuesByType(b, 'genre');
return explicit.length ? explicit : tagValuesByType(b, 'subject');
}
function bookSubgenres(b) { return tagValuesByType(b, 'subgenre'); }
function bookPlainTags(b) { return tagValuesByType(b, 'tag'); }
// Filter a list of books by a free-text query across all searchable fields.
function filterBooks(books, query) {
const q = String(query || '').trim().toLowerCase();
if (!q) return books;
return books.filter(b =>
bookTitle(b).toLowerCase().includes(q) ||
bookAuthor(b).toLowerCase().includes(q) ||
(b.publisher || '').toLowerCase().includes(q) ||
bookGenres(b).some(g => g.toLowerCase().includes(q)) ||
bookSubgenres(b).some(g => g.toLowerCase().includes(q)) ||
bookPlainTags(b).some(g => g.toLowerCase().includes(q))
);
}
// Wire up a search input: show/hide clear button on input, trigger onSearch(query) on Enter.
function setupSearchInput(inputId, clearId, onSearch) {
const input = document.getElementById(inputId);
const clear = document.getElementById(clearId);
if (!input) return;
input.addEventListener('input', () => {
if (clear) clear.style.display = input.value.trim() ? '' : 'none';
});
input.addEventListener('keydown', e => {
if (e.key === 'Enter') { e.preventDefault(); onSearch(input.value.trim()); }
});
}

View File

@ -0,0 +1,84 @@
// ── Shared SSE conversion stream handler ─────────────────────────────────────
// Requires: books.js (for esc())
function addLog(msg, cls) {
const div = document.getElementById('log-lines');
const span = document.createElement('span');
if (cls) span.className = cls;
span.textContent = msg;
span.style.display = 'block';
div.appendChild(span);
div.scrollTop = div.scrollHeight;
}
function connectConversionStream(job_id) {
const es = new EventSource(`/events/${job_id}`);
es.addEventListener('status', e => {
const d = JSON.parse(e.data);
document.getElementById('status-line').textContent = d.message;
addLog(d.message);
});
es.addEventListener('meta', e => {
const d = JSON.parse(e.data);
document.getElementById('status-line').textContent = `"${d.title}" by ${d.author}`;
});
es.addEventListener('chapters', e => {
const d = JSON.parse(e.data);
const ul = document.getElementById('chapter-list');
ul.innerHTML = '';
d.chapters.forEach((title, i) => {
const li = document.createElement('li');
li.className = 'chapter-item';
li.id = `ch-${i}`;
li.innerHTML = `<span class="dot"></span><span>${esc(title)}</span>`;
ul.appendChild(li);
});
});
es.addEventListener('progress', e => {
const d = JSON.parse(e.data);
document.getElementById('progress-bar').style.width =
Math.round((d.current / d.total) * 100) + '%';
document.getElementById('status-line').textContent =
`Chapter ${d.current} of ${d.total}: ${d.title}`;
if (d.current > 1) {
const prev = document.getElementById(`ch-${d.current - 2}`);
if (prev) prev.className = 'chapter-item done';
}
const cur = document.getElementById(`ch-${d.current - 1}`);
if (cur) { cur.className = 'chapter-item active'; cur.scrollIntoView({ block: 'nearest' }); }
});
es.addEventListener('warning', e => {
addLog(JSON.parse(e.data).message, 'warn');
});
es.addEventListener('error', e => {
const d = JSON.parse(e.data);
addLog(d.message, 'err');
document.getElementById('status-line').textContent = '❌ ' + d.message;
document.getElementById('convert-btn').disabled = false;
es.close();
});
es.addEventListener('done', e => {
const d = JSON.parse(e.data);
document.getElementById('progress-bar').style.width = '100%';
document.getElementById('status-line').textContent = 'Done ✓';
document.querySelectorAll('.chapter-item').forEach(el => el.className = 'chapter-item done');
document.getElementById('result-meta').innerHTML =
`<strong>${esc(d.title)}</strong><br/>${d.chapters} chapters successfully converted`;
document.getElementById('download-btn').onclick = () => {
window.location = `/download/${encodeURIComponent(d.filename)}`;
};
document.getElementById('book-detail-btn').onclick = () => {
window.location = `/library/book/${encodeURIComponent(d.filename)}`;
};
document.getElementById('result-card').classList.add('visible');
document.getElementById('convert-btn').disabled = false;
es.close();
});
}

View File

@ -1,10 +1,5 @@
:root {
--bg: #0f0e0c; --surface: #1a1815; --surface2: #221f1b;
--border: #2e2a24; --accent: #c8783a; --text: #e8e2d9;
--text-dim: #8a8278; --text-faint: #4a453e;
--success: #6baa6b; --danger: #c85a5a;
--radius: 6px;
--mono: 'DM Mono', monospace;
--danger: #c85a5a;
--header-h: 50px;
--panel-w: 240px;
}
@ -62,7 +57,7 @@ html, body { height: 100%; background: var(--bg); color: var(--text); font-famil
font-family: var(--mono); font-size: 0.72rem; color: var(--accent);
cursor: pointer; transition: background 0.12s;
}
.btn-save-all:hover { background: rgba(200,120,58,0.12); }
.btn-save-all:hover { background: rgba(255,162,14,0.12); }
.btn-break {
display: flex; align-items: center; gap: 0.35rem;

View File

@ -424,7 +424,3 @@ function setStatus(cls, text) {
el.className = 'save-status' + (cls ? ' ' + cls : '');
el.textContent = text;
}
function esc(s) {
return String(s ?? '').replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 762 B

View File

@ -1,24 +1,5 @@
/* ── Novela — Library page styles ─────────────────────────────────────── */
: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 {
@ -83,7 +64,7 @@ html, body {
.import-dropzone:hover { border-color: var(--accent); }
.import-dropzone.dragover {
border-color: var(--accent2);
background: rgba(200, 120, 58, 0.12);
background: rgba(255, 162, 14, 0.12);
}
.import-dropzone.uploading {
opacity: 0.8;
@ -157,7 +138,7 @@ html, body {
.badge-complete { color: #6baa6b; }
.badge-ongoing { color: #4a90b8; }
.badge-temporary-hold { color: #c8a03a; }
.badge-long-term-hold { color: #c8783a; }
.badge-long-term-hold { color: #ffa20e; }
/* Star: want-to-read top-left */
.btn-star {
@ -217,7 +198,7 @@ html, body {
/* Read count pill */
.read-pill {
position: absolute; bottom: 0.35rem; right: 0.35rem;
background: rgba(200,120,58,0.88); color: #0f0e0c;
background: rgba(255,162,14,0.88); color: #0f0e0c;
font-family: var(--mono); font-size: 0.6rem; font-weight: 500;
padding: 0.1rem 0.38rem; border-radius: 3px; z-index: 2; pointer-events: none;
}
@ -226,7 +207,7 @@ html, body {
.progress-mini {
position: absolute; bottom: 0; left: 0; right: 0;
height: 3px; z-index: 2; pointer-events: none;
background: rgba(200,120,58,0.25);
background: rgba(255,162,14,0.25);
}
.progress-mini-fill { height: 100%; background: var(--accent); }
@ -452,13 +433,13 @@ html, body {
}
.publisher-missing-wrap {
border: 1px solid rgba(200, 120, 58, 0.28);
border: 1px solid rgba(255, 162, 14, 0.28);
border-radius: var(--radius);
overflow: hidden;
}
.publisher-missing-item {
background: rgba(200, 120, 58, 0.08);
background: rgba(255, 162, 14, 0.08);
}
.publisher-divider {
@ -542,8 +523,8 @@ html, body {
}
.btn.btn-view.active {
border-color: rgba(200, 120, 58, 0.45);
background: rgba(200, 120, 58, 0.16);
border-color: rgba(255, 162, 14, 0.45);
background: rgba(255, 162, 14, 0.16);
color: var(--accent2);
}
@ -663,7 +644,7 @@ html, body {
}
.new-list-table tbody tr:hover {
background: rgba(200, 120, 58, 0.08);
background: rgba(255, 162, 14, 0.08);
}
.new-col-select {

View File

@ -53,72 +53,6 @@ let allVisibleColumns = loadAllVisibleColumns();
let allSelectedFilenames = new Set();
let allLastToggledIndex = null;
// ── Placeholder cover generation ───────────────────────────────────────────
function strHash(s) {
let h = 0;
for (let i = 0; i < s.length; i++) h = (Math.imul(31, h) + s.charCodeAt(i)) | 0;
return Math.abs(h);
}
const COVER_PALETTES = [
['#1a2a3a', '#4a8caa'],
['#2a1a1a', '#aa4a4a'],
['#1a2a1a', '#4aaa6a'],
['#2a1a2a', '#8a4aaa'],
['#2a2a1a', '#aaa04a'],
['#1a2a2a', '#4aaa9a'],
['#2a1a14', '#c8783a'],
['#141a2a', '#5a78c8'],
];
function makePlaceholderCover(canvas, title, author) {
const w = canvas.width = canvas.offsetWidth || 150;
const h = canvas.height = canvas.offsetHeight || 225;
const ctx = canvas.getContext('2d');
const [bg, fg] = COVER_PALETTES[strHash(title) % COVER_PALETTES.length];
ctx.fillStyle = bg;
ctx.fillRect(0, 0, w, h);
ctx.fillStyle = fg;
ctx.globalAlpha = 0.15;
ctx.fillRect(0, 0, w, h * 0.08);
ctx.globalAlpha = 1;
ctx.fillStyle = fg;
ctx.fillRect(w * 0.12, h * 0.12, w * 0.04, h * 0.55);
ctx.fillStyle = '#e8e2d9';
ctx.font = `bold ${Math.round(w * 0.105)}px 'Libre Baskerville', Georgia, serif`;
ctx.textAlign = 'center';
wrapText(ctx, title, w * 0.55, h * 0.28, w * 0.72, Math.round(w * 0.12));
ctx.fillStyle = fg;
ctx.font = `${Math.round(w * 0.075)}px 'DM Mono', monospace`;
ctx.globalAlpha = 0.85;
ctx.fillText(truncate(author, 18), w * 0.55, h * 0.86);
ctx.globalAlpha = 1;
}
function wrapText(ctx, text, x, y, maxW, lineH) {
const words = text.split(' ');
let line = '';
let lines = [];
for (const word of words) {
const test = line ? line + ' ' + word : word;
if (ctx.measureText(test).width > maxW && line) { lines.push(line); line = word; }
else line = test;
}
if (line) lines.push(line);
lines = lines.slice(0, 4);
const startY = y - ((lines.length - 1) * lineH) / 2;
lines.forEach((l, i) => ctx.fillText(l, x, startY + i * lineH));
}
function truncate(s, n) { return s.length > n ? s.slice(0, n - 1) + '…' : s; }
// ── Data loading ───────────────────────────────────────────────────────────
let _libraryETag = null;
@ -182,25 +116,11 @@ function updateCounts() {
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 dupCount = dupGroups.reduce((s, g) => s + g.length, 0);
const dupEl = document.getElementById('count-duplicates');
if (dupEl) dupEl.textContent = dupCount || '';
}
function _filenameBase(filename) {
const leaf = String(filename || '').split('/').pop() || '';
return leaf.replace(/\.[^.]+$/, '');
}
function bookAuthor(b) {
if (b.author) return b.author;
const parts = _filenameBase(b.filename).split('-');
return (parts[1] ?? '').replace(/_/g, ' ');
}
function bookTitle(b) {
return b.title || (_filenameBase(b.filename).split('-')[2] ?? '').replace(/_/g, ' ');
}
function normalizePublisherName(value) {
const v = (value || '').trim();
@ -499,27 +419,6 @@ async function markSelectedNewAsReviewed(books) {
}
}
function tagValuesByType(book, type) {
return (book.tags || [])
.filter(t => t && t.tag_type === type && t.tag)
.map(t => t.tag);
}
function bookGenres(book) {
const explicit = tagValuesByType(book, 'genre');
if (explicit.length) return explicit;
return (book.tags || [])
.filter(t => t && t.tag_type === 'subject' && t.tag)
.map(t => t.tag);
}
function bookSubgenres(book) {
return tagValuesByType(book, 'subgenre');
}
function bookPlainTags(book) {
return tagValuesByType(book, 'tag');
}
function formatUpdated(iso) {
if (!iso) return '';
@ -1440,15 +1339,7 @@ function renderGenreView(tag) {
function renderSearchResults(query) {
if (!query) { renderBooksGrid(activeBooks()); return; }
const q = query.toLowerCase();
const books = activeBooks().filter(b =>
bookTitle(b).toLowerCase().includes(q) ||
bookAuthor(b).toLowerCase().includes(q) ||
bookGenres(b).some(g => g.toLowerCase().includes(q)) ||
bookSubgenres(b).some(g => g.toLowerCase().includes(q)) ||
bookPlainTags(b).some(g => g.toLowerCase().includes(q))
);
renderBooksGrid(books);
renderBooksGrid(filterBooks(activeBooks(), query));
}
function clearSearch() {
@ -1549,7 +1440,12 @@ function renderRatedView() {
function _duplicateGroups(books) {
const map = new Map();
books.forEach(b => {
const key = (bookTitle(b).trim().toLowerCase()) + '|' + (bookAuthor(b).trim().toLowerCase());
const title = bookTitle(b).trim().toLowerCase();
const author = bookAuthor(b).trim().toLowerCase();
// Volume known: include it in the key so different volumes are not flagged as duplicates.
// No volume: key on title+author only.
const vol = (b.series_index > 0) ? '|vol:' + b.series_index : '';
const key = title + '|' + author + vol;
if (!map.has(key)) map.set(key, []);
map.get(key).push(b);
});
@ -1851,20 +1747,13 @@ async function uploadImportedFiles(files) {
// ── Utilities ──────────────────────────────────────────────────────────────
function esc(s) {
return String(s ?? '').replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
}
function jsEsc(s) { return String(s ?? '').replace(/\\/g, '\\\\').replace(/'/g, "\\'"); }
function cssId(filename) { return filename.replace(/[^a-zA-Z0-9_-]/g, '_'); }
// ── Search input ───────────────────────────────────────────────────────────
let searchTimer = null;
document.getElementById('search-input').addEventListener('input', function() {
const q = this.value.trim();
document.getElementById('search-clear').style.display = q ? '' : 'none';
clearTimeout(searchTimer);
searchTimer = setTimeout(() => {
function triggerSearch() {
const q = document.getElementById('search-input').value.trim();
if (q) {
currentView = 'search';
currentParam = q;
@ -1878,8 +1767,9 @@ document.getElementById('search-input').addEventListener('input', function() {
} else {
switchView('all');
}
}, 250);
});
}
setupSearchInput('search-input', 'search-clear', () => triggerSearch());
// ── Init ───────────────────────────────────────────────────────────────────

Binary file not shown.

After

Width:  |  Height:  |  Size: 429 KiB

View File

@ -24,6 +24,16 @@ html {
border-bottom: 1px solid var(--border);
margin-bottom: 1rem;
}
.sidebar-logo a {
display: flex;
align-items: center;
gap: 0.4rem;
}
.sidebar-logo-img {
width: 26px;
height: auto;
flex-shrink: 0;
}
.sidebar-logo h1 {
margin: 0;
font-size: 1.25rem;
@ -31,13 +41,6 @@ html {
letter-spacing: -0.02em;
}
.sidebar-logo h1 span { color: var(--accent); }
.sidebar-logo p {
font-family: var(--mono);
font-size: 0.62rem;
color: var(--text-dim);
letter-spacing: 0.1em;
margin-top: 0.2rem;
}
.sidebar-section-label {
font-family: var(--mono);

View File

@ -0,0 +1,20 @@
/* ── Novela — shared CSS custom properties ──────────────────────────────── */
:root {
--bg: #0f0e0c;
--surface: #1a1815;
--surface2: #221f1b;
--border: #2e2a24;
--accent: #ffa20e;
--accent2: #ffb840;
--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;
}

View File

@ -9,7 +9,10 @@
<aside class="sidebar" id="sidebar">
<div class="sidebar-logo">
<a href="/home" style="text-decoration:none;color:inherit"><h1>No<span>vela</span></h1></a>
<a href="/home" style="text-decoration:none;color:inherit">
<img src="/static/logo.png" alt="N" class="sidebar-logo-img"/>
<h1>No<span>vela</span></h1>
</a>
</div>
<ul class="sidebar-nav">
@ -244,6 +247,18 @@
Settings
</a>
</li>
<li>
<a href="/changelog"{% if active == 'changelog' %} class="active"{% endif %}>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/>
<polyline points="14 2 14 8 20 8"/>
<line x1="16" y1="13" x2="8" y2="13"/>
<line x1="16" y1="17" x2="8" y2="17"/>
<polyline points="10 9 9 9 8 9"/>
</svg>
Changelog
</a>
</li>
</ul>
<div class="sidebar-bottom">

View File

@ -4,17 +4,17 @@
<meta charset="UTF-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<title>Novela - Backup</title>
<link rel="icon" href="/static/favicon.ico" sizes="16x16"/>
<link rel="icon" type="image/png" sizes="32x32" href="/static/favicon-32.png"/>
<link rel="icon" type="image/png" sizes="256x256" href="/static/favicon-256.png"/>
<link rel="apple-touch-icon" sizes="180x180" href="/static/apple-touch-icon.png"/>
<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/theme.css"/>
<link rel="stylesheet" href="/static/sidebar.css"/>
<style>
:root {
--bg: #0f0e0c; --surface: #1a1815; --surface2: #221f1b;
--border: #2e2a24; --accent: #c8783a;
--text: #e8e2d9; --text-dim: #8a8278;
--ok: #7fbe7f; --warn: #d2b063; --err: #d0674c;
--sidebar: 220px; --radius: 8px;
--mono: 'DM Mono', monospace; --serif: 'Libre Baskerville', Georgia, serif;
}
* { box-sizing: border-box; }
html, body { margin: 0; min-height: 100%; background: var(--bg); color: var(--text); font-family: var(--serif); }
@ -87,7 +87,7 @@
cursor: pointer;
}
.btn:hover { border-color: var(--accent); }
.btn.primary { border-color: rgba(200,120,58,0.45); background: rgba(200,120,58,0.12); }
.btn.primary { border-color: rgba(255,162,14,0.45); background: rgba(255,162,14,0.12); }
.btn:disabled { opacity: 0.5; cursor: not-allowed; }
.field-label {
@ -238,11 +238,8 @@
</section>
</main>
<script src="/static/books.js"></script>
<script>
function esc(v) {
return String(v ?? '').replace(/[&<>"']/g, (c) => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
}
function rowHtml(k, v) {
return `<div class="row"><div class="k">${esc(k)}</div><div class="v">${esc(v)}</div></div>`;
}

View File

@ -4,8 +4,13 @@
<meta charset="UTF-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<title>Novela — {{ title or filename }}</title>
<link rel="icon" href="/static/favicon.ico" sizes="16x16"/>
<link rel="icon" type="image/png" sizes="32x32" href="/static/favicon-32.png"/>
<link rel="icon" type="image/png" sizes="256x256" href="/static/favicon-256.png"/>
<link rel="apple-touch-icon" sizes="180x180" href="/static/apple-touch-icon.png"/>
<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/theme.css"/>
<link rel="stylesheet" href="/static/sidebar.css"/>
<link rel="stylesheet" href="/static/book.css"/>
</head>
@ -344,6 +349,7 @@
rating: {{ rating or 0 }},
};
</script>
<script src="/static/books.js"></script>
<script src="/static/book.js"></script>
</body>
</html>

View File

@ -4,6 +4,11 @@
<meta charset="UTF-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<title>Novela — Book Builder{% if view == 'editor' %}: {{ draft.title }}{% endif %}</title>
<link rel="icon" href="/static/favicon.ico" sizes="16x16"/>
<link rel="icon" type="image/png" sizes="32x32" href="/static/favicon-32.png"/>
<link rel="icon" type="image/png" sizes="256x256" href="/static/favicon-256.png"/>
<link rel="apple-touch-icon" sizes="180x180" href="/static/apple-touch-icon.png"/>
<link rel="stylesheet" href="/static/theme.css"/>
<link rel="stylesheet" href="/static/library.css"/>
<link rel="stylesheet" href="/static/sidebar.css"/>
<link rel="stylesheet" href="/static/builder.css"/>

View File

@ -4,28 +4,15 @@
<meta charset="UTF-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<title>Novela Bulk Import</title>
<link rel="icon" href="/static/favicon.ico" sizes="16x16"/>
<link rel="icon" type="image/png" sizes="32x32" href="/static/favicon-32.png"/>
<link rel="icon" type="image/png" sizes="256x256" href="/static/favicon-256.png"/>
<link rel="apple-touch-icon" sizes="180x180" href="/static/apple-touch-icon.png"/>
<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/theme.css"/>
<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%; }
body { background: var(--bg); color: var(--text); font-family: var(--serif); }
@ -181,7 +168,7 @@
}
tbody tr { border-bottom: 1px solid var(--border); }
tbody tr:last-child { border-bottom: none; }
tbody tr:hover { background: rgba(200,120,58,0.04); }
tbody tr:hover { background: rgba(255,162,14,0.04); }
tbody tr.row-warn { background: rgba(200,160,58,0.06); }
tbody tr.row-warn:hover { background: rgba(200,160,58,0.10); }
td {
@ -191,7 +178,7 @@
td.td-filename { color: var(--text-dim); max-width: 200px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
td[contenteditable="true"] { outline: none; cursor: text; min-width: 60px; }
td[contenteditable="true"]:focus {
background: rgba(200,120,58,0.08);
background: rgba(255,162,14,0.08);
box-shadow: inset 0 0 0 1px var(--accent);
}
td[contenteditable="true"]:empty::before {
@ -441,6 +428,7 @@
</main>
<script src="/static/books.js"></script>
<script>
// ── State ──────────────────────────────────────────────────────────────────
const PLACEHOLDER_META = [
@ -873,10 +861,6 @@
window.location.href = '/library#new';
}
function esc(s) {
return String(s ?? '')
.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}
// ── TextSuggest ────────────────────────────────────────────────────────────
class TextSuggest {

View File

@ -0,0 +1,130 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<title>Novela — Changelog</title>
<link rel="icon" href="/static/favicon.ico" sizes="16x16"/>
<link rel="icon" type="image/png" sizes="32x32" href="/static/favicon-32.png"/>
<link rel="icon" type="image/png" sizes="256x256" href="/static/favicon-256.png"/>
<link rel="apple-touch-icon" sizes="180x180" href="/static/apple-touch-icon.png"/>
<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/theme.css"/>
<link rel="stylesheet" href="/static/sidebar.css"/>
<style>
html, body { margin: 0; padding: 0; background: var(--bg); color: var(--text); font-family: 'Libre Baskerville', serif; font-size: 15px; }
.main-content {
margin-left: var(--sidebar);
padding: 2.5rem 3rem;
max-width: 860px;
}
.page-header { margin-bottom: 2.5rem; }
.page-header h1 { margin: 0 0 0.25rem; font-size: 1.6rem; font-weight: 700; }
.page-header p { margin: 0; color: var(--text-dim); font-size: 0.85rem; font-family: var(--mono); }
.version-block { margin-bottom: 2.5rem; }
.version-header {
display: flex;
align-items: baseline;
gap: 1rem;
margin-bottom: 0.75rem;
padding-bottom: 0.6rem;
border-bottom: 1px solid var(--border);
}
.version-tag {
font-family: var(--mono);
font-size: 1rem;
font-weight: 600;
color: var(--accent);
}
.version-date {
font-family: var(--mono);
font-size: 0.75rem;
color: var(--text-dim);
}
.version-summary {
margin: 0 0 1.25rem;
color: var(--text-dim);
font-size: 0.9rem;
line-height: 1.6;
}
.section { margin-bottom: 1.25rem; }
.section-title {
display: inline-flex;
align-items: center;
gap: 0.4rem;
font-family: var(--mono);
font-size: 0.7rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.06em;
padding: 0.2rem 0.55rem;
border-radius: 3px;
margin-bottom: 0.6rem;
}
.section-title.feature { background: rgba(74,144,184,0.15); color: #4a90b8; }
.section-title.improvement { background: rgba(107,170,107,0.15); color: var(--success); }
.section-title.bugfix { background: rgba(200,90,58,0.15); color: var(--error); }
.section-title.security { background: rgba(200,160,58,0.15); color: var(--warning); }
.change-list {
margin: 0;
padding: 0 0 0 1.1rem;
list-style: none;
}
.change-list li {
position: relative;
padding: 0.2rem 0 0.2rem 0.9rem;
color: var(--text);
font-size: 0.88rem;
line-height: 1.55;
}
.change-list li::before {
content: '';
position: absolute;
left: 0;
color: var(--text-faint);
}
@media (max-width: 768px) {
.main-content { margin-left: 0; padding: 1.25rem 1rem; }
}
</style>
</head>
<body>
{% include "_sidebar.html" %}
<div class="main-content">
<div class="page-header">
<h1>Changelog</h1>
<p>Release history and updates</p>
</div>
{% for entry in changelog %}
<div class="version-block" id="{{ entry.version }}">
<div class="version-header">
<span class="version-tag">{{ entry.version }}</span>
<span class="version-date">{{ entry.date }}</span>
</div>
{% if entry.summary %}
<p class="version-summary">{{ entry.summary }}</p>
{% endif %}
{% for section in entry.sections %}
<div class="section">
<div class="section-title {{ section.type }}">{{ section.title }}</div>
<ul class="change-list">
{% for change in section.changes %}
<li>{{ change }}</li>
{% endfor %}
</ul>
</div>
{% endfor %}
</div>
{% endfor %}
</div>
</body>
</html>

View File

@ -4,29 +4,15 @@
<meta charset="UTF-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<title>Novela — Credentials</title>
<link rel="icon" href="/static/favicon.ico" sizes="16x16"/>
<link rel="icon" type="image/png" sizes="32x32" href="/static/favicon-32.png"/>
<link rel="icon" type="image/png" sizes="256x256" href="/static/favicon-256.png"/>
<link rel="apple-touch-icon" sizes="180x180" href="/static/apple-touch-icon.png"/>
<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/theme.css"/>
<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%; }
@ -295,6 +281,7 @@
</main>
<script src="/static/books.js"></script>
<script>
let allCredentials = {};

View File

@ -4,29 +4,15 @@
<meta charset="UTF-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<title>Novela — Debug</title>
<link rel="icon" href="/static/favicon.ico" sizes="16x16"/>
<link rel="icon" type="image/png" sizes="32x32" href="/static/favicon-32.png"/>
<link rel="icon" type="image/png" sizes="256x256" href="/static/favicon-256.png"/>
<link rel="apple-touch-icon" sizes="180x180" href="/static/apple-touch-icon.png"/>
<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/theme.css"/>
<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%; }
@ -178,6 +164,7 @@
</main>
<script src="/static/books.js"></script>
<script>
async function runInspect() {
const url = document.getElementById('url').value.trim();
@ -226,10 +213,6 @@
toggle.textContent = collapsed ? '▼ expand' : '▲ collapse';
}
function esc(s) {
return String(s ?? '')
.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
}
function metaRow(label, value) {
return `<div class="meta-row"><span class="meta-label">${label}</span><span class="meta-value">${value}</span></div>`;

View File

@ -4,8 +4,13 @@
<meta charset="UTF-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<title>Novela — Edit {{ title or filename }}</title>
<link rel="icon" href="/static/favicon.ico" sizes="16x16"/>
<link rel="icon" type="image/png" sizes="32x32" href="/static/favicon-32.png"/>
<link rel="icon" type="image/png" sizes="256x256" href="/static/favicon-256.png"/>
<link rel="apple-touch-icon" sizes="180x180" href="/static/apple-touch-icon.png"/>
<link rel="preconnect" href="https://fonts.googleapis.com"/>
<link href="https://fonts.googleapis.com/css2?family=DM+Mono:wght@400;500&display=swap" rel="stylesheet"/>
<link rel="stylesheet" href="/static/theme.css"/>
<link rel="stylesheet" href="/static/editor.css"/>
</head>
<body>
@ -92,6 +97,7 @@
};
</script>
<script src="https://cdn.jsdelivr.net/npm/monaco-editor@0.45.0/min/vs/loader.js"></script>
<script src="/static/books.js"></script>
<script src="/static/editor.js"></script>
</body>
</html>

View File

@ -4,18 +4,15 @@
<meta charset="UTF-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<title>Novela — Following</title>
<link rel="icon" href="/static/favicon.ico" sizes="16x16"/>
<link rel="icon" type="image/png" sizes="32x32" href="/static/favicon-32.png"/>
<link rel="icon" type="image/png" sizes="256x256" href="/static/favicon-256.png"/>
<link rel="apple-touch-icon" sizes="180x180" href="/static/apple-touch-icon.png"/>
<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/theme.css"/>
<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); }
@ -137,20 +134,8 @@
</div>
<div id="author-list"><div class="loading">Loading…</div></div>
</main>
<script src="/static/books.js"></script>
<script>
const COVER_PALETTES = [
['#1a2a3a','#4a8caa'],['#2a1a1a','#aa4a4a'],['#1a2a1a','#4aaa6a'],
['#2a1a2a','#8a4aaa'],['#2a2a1a','#aaa04a'],['#1a2a2a','#4aaa9a'],
['#2a1a14','#c8783a'],['#141a2a','#5a78c8'],
];
function strHash(s) {
let h = 0;
for (let i = 0; i < s.length; i++) h = (Math.imul(31, h) + s.charCodeAt(i)) | 0;
return Math.abs(h);
}
function esc(s) {
return (s || '').replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
}
function timeAgo(isoStr) {
if (!isoStr) return '';
const s = /[Zz+\-]\d*$/.test(isoStr.trim()) ? isoStr : isoStr + 'Z';

View File

@ -4,29 +4,15 @@
<meta charset="UTF-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<title>Novela</title>
<link rel="icon" href="/static/favicon.ico" sizes="16x16"/>
<link rel="icon" type="image/png" sizes="32x32" href="/static/favicon-32.png"/>
<link rel="icon" type="image/png" sizes="256x256" href="/static/favicon-256.png"/>
<link rel="apple-touch-icon" sizes="180x180" href="/static/apple-touch-icon.png"/>
<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/theme.css"/>
<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%; }
@ -324,6 +310,8 @@
</main>
<script src="/static/books.js"></script>
<script src="/static/conversion.js"></script>
<script>
let currentUrl = '';
let coverB64 = null;
@ -487,85 +475,7 @@
document.getElementById('convert-label').textContent = 'Convert';
document.getElementById('convert-spinner').style.display = 'none';
const es = new EventSource(`/events/${job_id}`);
es.addEventListener('status', e => {
const d = JSON.parse(e.data);
document.getElementById('status-line').textContent = d.message;
addLog(d.message);
});
es.addEventListener('meta', e => {
const d = JSON.parse(e.data);
document.getElementById('status-line').textContent = `"${d.title}" by ${d.author}`;
});
es.addEventListener('chapters', e => {
const d = JSON.parse(e.data);
const ul = document.getElementById('chapter-list');
ul.innerHTML = '';
d.chapters.forEach((title, i) => {
const li = document.createElement('li');
li.className = 'chapter-item';
li.id = `ch-${i}`;
li.innerHTML = `<span class="dot"></span><span>${esc(title)}</span>`;
ul.appendChild(li);
});
});
es.addEventListener('progress', e => {
const d = JSON.parse(e.data);
document.getElementById('progress-bar').style.width =
Math.round((d.current / d.total) * 100) + '%';
document.getElementById('status-line').textContent =
`Chapter ${d.current} of ${d.total}: ${d.title}`;
if (d.current > 1) {
const prev = document.getElementById(`ch-${d.current - 2}`);
if (prev) prev.className = 'chapter-item done';
}
const cur = document.getElementById(`ch-${d.current - 1}`);
if (cur) { cur.className = 'chapter-item active'; cur.scrollIntoView({ block: 'nearest' }); }
});
es.addEventListener('warning', e => {
addLog(JSON.parse(e.data).message, 'warn');
});
es.addEventListener('error', e => {
const d = JSON.parse(e.data);
addLog(d.message, 'err');
document.getElementById('status-line').textContent = '❌ ' + d.message;
document.getElementById('convert-btn').disabled = false;
es.close();
});
es.addEventListener('done', e => {
const d = JSON.parse(e.data);
document.getElementById('progress-bar').style.width = '100%';
document.getElementById('status-line').textContent = 'Done ✓';
document.querySelectorAll('.chapter-item').forEach(el => el.className = 'chapter-item done');
document.getElementById('result-meta').innerHTML =
`<strong>${esc(d.title)}</strong><br/>${d.chapters} chapters successfully converted`;
document.getElementById('download-btn').onclick = () => {
window.location = `/download/${encodeURIComponent(d.filename)}`;
};
document.getElementById('book-detail-btn').onclick = () => {
window.location = `/library/book/${encodeURIComponent(d.filename)}`;
};
document.getElementById('result-card').classList.add('visible');
document.getElementById('convert-btn').disabled = false;
es.close();
});
}
function addLog(msg, cls) {
const div = document.getElementById('log-lines');
const span = document.createElement('span');
if (cls) span.className = cls;
span.textContent = msg;
span.style.display = 'block';
div.appendChild(span);
div.scrollTop = div.scrollHeight;
connectConversionStream(job_id);
}
function clearDupWarning() {
@ -584,10 +494,6 @@
el.classList.add('visible');
}
function esc(s) {
return String(s ?? '')
.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
}
</script>
</body>
</html>

View File

@ -4,18 +4,15 @@
<meta charset="UTF-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<title>Novela — Home</title>
<link rel="icon" href="/static/favicon.ico" sizes="16x16"/>
<link rel="icon" type="image/png" sizes="32x32" href="/static/favicon-32.png"/>
<link rel="icon" type="image/png" sizes="256x256" href="/static/favicon-256.png"/>
<link rel="apple-touch-icon" sizes="180x180" href="/static/apple-touch-icon.png"/>
<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/theme.css"/>
<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); }
@ -66,7 +63,7 @@
.import-dropzone:hover { border-color: var(--accent); }
.import-dropzone.dragover {
border-color: var(--accent2);
background: rgba(200, 120, 58, 0.12);
background: rgba(255, 162, 14, 0.12);
}
.import-dropzone.uploading {
opacity: 0.8;
@ -130,7 +127,7 @@
font-family: var(--mono); font-size: 0.6rem; color: var(--text-dim);
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.h-progress-bar { height: 3px; background: rgba(200,120,58,0.2); border-radius: 2px; margin-bottom: 0.25rem; }
.h-progress-bar { height: 3px; background: rgba(255,162,14,0.2); border-radius: 2px; margin-bottom: 0.25rem; }
.h-progress-fill { height: 100%; background: var(--accent); border-radius: 2px; }
.h-pct { font-family: var(--mono); font-size: 0.6rem; color: var(--text-dim); }
@ -163,7 +160,7 @@
.progress-mini {
position: absolute; bottom: 0; left: 0; right: 0;
height: 3px; z-index: 2; pointer-events: none;
background: rgba(200,120,58,0.25);
background: rgba(255,162,14,0.25);
}
.progress-mini-fill { height: 100%; background: var(--accent); }
.book-info { padding: 0.5rem 0.2rem 0; }
@ -287,6 +284,7 @@
</div>
</main>
<script src="/static/books.js"></script>
<script>
let data = { continue_reading: [], shorts_unread: [], novels_unread: [], shorts_read: [], novels_read: [] };
let currentView = 'home';
@ -295,45 +293,6 @@
let allBooks = [];
const IMPORT_EXTENSIONS = ['.epub', '.pdf', '.cbr', '.cbz'];
function strHash(s) {
let h = 0;
for (let i = 0; i < s.length; i++) h = (Math.imul(31, h) + s.charCodeAt(i)) | 0;
return Math.abs(h);
}
const PALETTES = [
['#1a2a3a','#4a8caa'],['#2a1a1a','#aa4a4a'],['#1a2a1a','#4aaa6a'],['#2a1a2a','#8a4aaa'],
['#2a2a1a','#aaa04a'],['#1a2a2a','#4aaa9a'],['#2a1a14','#c8783a'],['#141a2a','#5a78c8'],
];
function makePlaceholder(canvas, title, author) {
const w = canvas.width = canvas.offsetWidth || 150;
const h = canvas.height = canvas.offsetHeight || 225;
const ctx = canvas.getContext('2d');
const [bg, fg] = PALETTES[strHash(title) % PALETTES.length];
ctx.fillStyle = bg; ctx.fillRect(0, 0, w, h);
ctx.fillStyle = fg; ctx.globalAlpha = 0.15; ctx.fillRect(0, 0, w, h * 0.08); ctx.globalAlpha = 1;
ctx.fillStyle = fg; ctx.fillRect(w * 0.12, h * 0.12, w * 0.04, h * 0.55);
ctx.fillStyle = '#e8e2d9';
ctx.font = `bold ${Math.round(w * 0.105)}px 'Libre Baskerville', Georgia, serif`;
ctx.textAlign = 'center';
wrapText(ctx, title, w * 0.55, h * 0.28, w * 0.72, Math.round(w * 0.12));
ctx.fillStyle = fg; ctx.font = `${Math.round(w * 0.075)}px 'DM Mono', monospace`;
ctx.globalAlpha = 0.85; ctx.fillText(trunc(author, 18), w * 0.55, h * 0.86); ctx.globalAlpha = 1;
}
function wrapText(ctx, text, x, y, maxW, lineH) {
const words = text.split(' '); let line = '', lines = [];
for (const w of words) {
const t = line ? line + ' ' + w : w;
if (ctx.measureText(t).width > maxW && line) { lines.push(line); line = w; } else line = t;
}
if (line) lines.push(line); lines = lines.slice(0, 4);
const startY = y - ((lines.length - 1) * lineH) / 2;
lines.forEach((l, i) => ctx.fillText(l, x, startY + i * lineH));
}
function trunc(s, n) { return s.length > n ? s.slice(0, n - 1) + '…' : s; }
function esc(s) { return String(s || '').replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;'); }
function jsEsc(s) { return String(s || '').replace(/\\/g,'\\\\').replace(/'/g,"\\'"); }
function cssId(s) { return String(s || '').replace(/[^a-zA-Z0-9]/g, '_'); }
@ -365,15 +324,6 @@
if (row) row.outerHTML = starsHtml(filename, result.rating);
} catch {}
}
function filenameBase(filename) {
const leaf = String(filename || '').split('/').pop() || '';
return leaf.replace(/\.[^.]+$/, '');
}
function bookTitle(b) { return b.title || (filenameBase(b.filename).split('-')[2] ?? '').replace(/_/g, ' '); }
function bookAuthor(b) {
if (b.author) return b.author;
return (filenameBase(b.filename).split('-')[1] ?? '').replace(/_/g, ' ');
}
function attachCover(coverEl, canvasEl, b) {
const title = bookTitle(b);
@ -384,11 +334,11 @@
img.src = `/library/cover-cached/${encodeURIComponent(b.filename)}`;
img.alt = title;
img.onload = () => { canvasEl.style.display = 'none'; };
img.onerror = () => requestAnimationFrame(() => makePlaceholder(canvasEl, title, author));
img.onerror = () => requestAnimationFrame(() => makePlaceholderCover(canvasEl, title, author));
coverEl.style.position = 'relative';
coverEl.insertBefore(img, coverEl.firstChild);
}
requestAnimationFrame(() => makePlaceholder(canvasEl, title, author));
requestAnimationFrame(() => makePlaceholderCover(canvasEl, title, author));
}
function makeHCard(b, showProgress) {
@ -467,22 +417,16 @@
img.src = `/library/cover-cached/${encodeURIComponent(b.filename)}`;
img.alt = bookTitle(b);
img.onload = () => { canvas.style.display = 'none'; };
img.onerror = () => requestAnimationFrame(() => makePlaceholder(canvas, bookTitle(b), bookAuthor(b)));
img.onerror = () => requestAnimationFrame(() => makePlaceholderCover(canvas, bookTitle(b), bookAuthor(b)));
img.style.cssText = 'position:absolute;inset:0;width:100%;height:100%;object-fit:cover';
canvas.parentElement.insertBefore(img, canvas);
}
requestAnimationFrame(() => makePlaceholder(canvas, bookTitle(b), bookAuthor(b)));
requestAnimationFrame(() => makePlaceholderCover(canvas, bookTitle(b), bookAuthor(b)));
});
}
function searchResults(query) {
const q = String(query || '').trim().toLowerCase();
if (!q) return [];
return allBooks.filter(b =>
bookTitle(b).toLowerCase().includes(q) ||
bookAuthor(b).toLowerCase().includes(q) ||
(b.genres || []).some(g => String(g || '').toLowerCase().includes(q))
);
return filterBooks(allBooks, query);
}
function switchView(view) {
@ -528,16 +472,9 @@
}
function setupSearch() {
const input = document.getElementById('home-search-input');
const clear = document.getElementById('home-search-clear');
input.addEventListener('input', () => {
const q = input.value.trim();
clear.style.display = q ? '' : 'none';
clearTimeout(searchTimer);
searchTimer = setTimeout(() => {
setupSearchInput('home-search-input', 'home-search-clear', q => {
if (q) switchView('search');
else if (currentView === 'search') switchView('home');
}, 180);
});
}

View File

@ -4,29 +4,15 @@
<meta charset="UTF-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<title>Novela</title>
<link rel="icon" href="/static/favicon.ico" sizes="16x16"/>
<link rel="icon" type="image/png" sizes="32x32" href="/static/favicon-32.png"/>
<link rel="icon" type="image/png" sizes="256x256" href="/static/favicon-256.png"/>
<link rel="apple-touch-icon" sizes="180x180" href="/static/apple-touch-icon.png"/>
<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/theme.css"/>
<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%; }
@ -310,6 +296,8 @@
</main>
<script src="/static/books.js"></script>
<script src="/static/conversion.js"></script>
<script>
let currentUrl = '';
let coverB64 = null;
@ -472,91 +460,9 @@
document.getElementById('convert-label').textContent = 'Convert';
document.getElementById('convert-spinner').style.display = 'none';
const es = new EventSource(`/events/${job_id}`);
es.addEventListener('status', e => {
const d = JSON.parse(e.data);
document.getElementById('status-line').textContent = d.message;
addLog(d.message);
});
es.addEventListener('meta', e => {
const d = JSON.parse(e.data);
document.getElementById('status-line').textContent = `"${d.title}" by ${d.author}`;
});
es.addEventListener('chapters', e => {
const d = JSON.parse(e.data);
const ul = document.getElementById('chapter-list');
ul.innerHTML = '';
d.chapters.forEach((title, i) => {
const li = document.createElement('li');
li.className = 'chapter-item';
li.id = `ch-${i}`;
li.innerHTML = `<span class="dot"></span><span>${esc(title)}</span>`;
ul.appendChild(li);
});
});
es.addEventListener('progress', e => {
const d = JSON.parse(e.data);
document.getElementById('progress-bar').style.width =
Math.round((d.current / d.total) * 100) + '%';
document.getElementById('status-line').textContent =
`Chapter ${d.current} of ${d.total}: ${d.title}`;
if (d.current > 1) {
const prev = document.getElementById(`ch-${d.current - 2}`);
if (prev) prev.className = 'chapter-item done';
}
const cur = document.getElementById(`ch-${d.current - 1}`);
if (cur) { cur.className = 'chapter-item active'; cur.scrollIntoView({ block: 'nearest' }); }
});
es.addEventListener('warning', e => {
addLog(JSON.parse(e.data).message, 'warn');
});
es.addEventListener('error', e => {
const d = JSON.parse(e.data);
addLog(d.message, 'err');
document.getElementById('status-line').textContent = '❌ ' + d.message;
document.getElementById('convert-btn').disabled = false;
es.close();
});
es.addEventListener('done', e => {
const d = JSON.parse(e.data);
document.getElementById('progress-bar').style.width = '100%';
document.getElementById('status-line').textContent = 'Done ✓';
document.querySelectorAll('.chapter-item').forEach(el => el.className = 'chapter-item done');
document.getElementById('result-meta').innerHTML =
`<strong>${esc(d.title)}</strong><br/>${d.chapters} chapters successfully converted`;
document.getElementById('download-btn').onclick = () => {
window.location = `/download/${encodeURIComponent(d.filename)}`;
};
document.getElementById('book-detail-btn').onclick = () => {
window.location = `/library/book/${encodeURIComponent(d.filename)}`;
};
document.getElementById('result-card').classList.add('visible');
document.getElementById('convert-btn').disabled = false;
es.close();
});
connectConversionStream(job_id);
}
function addLog(msg, cls) {
const div = document.getElementById('log-lines');
const span = document.createElement('span');
if (cls) span.className = cls;
span.textContent = msg;
span.style.display = 'block';
div.appendChild(span);
div.scrollTop = div.scrollHeight;
}
function esc(s) {
return String(s ?? '')
.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
}
</script>
</body>
</html>

View File

@ -4,8 +4,13 @@
<meta charset="UTF-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<title>Novela — Library</title>
<link rel="icon" href="/static/favicon.ico" sizes="16x16"/>
<link rel="icon" type="image/png" sizes="32x32" href="/static/favicon-32.png"/>
<link rel="icon" type="image/png" sizes="256x256" href="/static/favicon-256.png"/>
<link rel="apple-touch-icon" sizes="180x180" href="/static/apple-touch-icon.png"/>
<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/theme.css"/>
<link rel="stylesheet" href="/static/sidebar.css"/>
<link rel="stylesheet" href="/static/library.css"/>
</head>
@ -93,6 +98,7 @@
</div>
</div>
<script src="/static/books.js"></script>
<script src="/static/library.js"></script>
</body>
</html>

View File

@ -4,15 +4,15 @@
<meta charset="UTF-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<title>Novela — {{ title }}</title>
<link rel="icon" href="/static/favicon.ico" sizes="16x16"/>
<link rel="icon" type="image/png" sizes="32x32" href="/static/favicon-32.png"/>
<link rel="icon" type="image/png" sizes="256x256" href="/static/favicon-256.png"/>
<link rel="apple-touch-icon" sizes="180x180" href="/static/apple-touch-icon.png"/>
<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/theme.css"/>
<style>
:root {
--bg: #0f0e0c; --surface: #1a1815; --surface2: #221f1b;
--border: #2e2a24; --accent: #c8783a; --text: #e8e2d9;
--text-dim: #8a8278; --text-faint: #4a453e; --success: #6baa6b;
--radius: 6px;
--mono: 'DM Mono', monospace; --serif: 'Libre Baskerville', Georgia, serif;
--header-h: 50px; --footer-h: 36px;
--content-w: 65vw;
}
@ -67,8 +67,8 @@
.btn-header:hover { color: var(--text); border-color: var(--text-faint); }
.btn-header-read { color: var(--success); border-color: rgba(107,170,107,0.3); }
.btn-header-read:hover { background: rgba(107,170,107,0.08); border-color: var(--success); }
.btn-header-bm { color: var(--accent); border-color: rgba(200,120,58,0.3); }
.btn-header-bm:hover { background: rgba(200,120,58,0.08); border-color: var(--accent); }
.btn-header-bm { color: var(--accent); border-color: rgba(255,162,14,0.3); }
.btn-header-bm:hover { background: rgba(255,162,14,0.08); border-color: var(--accent); }
/* ── Bookmark modal ── */
.bm-overlay {
@ -382,6 +382,7 @@
<div class="footer-pct" id="footer-pct">0%</div>
</div>
<script src="/static/books.js"></script>
<script>
const filename = {{ filename | tojson }};
const FORMAT = {{ format | tojson }};
@ -669,9 +670,6 @@
if (e.target === e.currentTarget) closeBookmarkModal();
});
function esc(s) {
return String(s ?? '').replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
}
init();
</script>

View File

@ -4,18 +4,15 @@
<meta charset="UTF-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<title>Novela — Settings</title>
<link rel="icon" href="/static/favicon.ico" sizes="16x16"/>
<link rel="icon" type="image/png" sizes="32x32" href="/static/favicon-32.png"/>
<link rel="icon" type="image/png" sizes="256x256" href="/static/favicon-256.png"/>
<link rel="apple-touch-icon" sizes="180x180" href="/static/apple-touch-icon.png"/>
<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/theme.css"/>
<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); }
@ -257,6 +254,7 @@
</div>
</div>
<script src="/static/books.js"></script>
<script>
// ── Break patterns ─────────────────────────────────────────────────────────
let bpPatterns = [];
@ -383,9 +381,6 @@
fb.textContent = '✗ Not recognized as a break. (CSS classes are not tested here — they apply to HTML attributes.)';
}
function esc(s) {
return String(s ?? '').replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}
// Enter key in add inputs
document.addEventListener('DOMContentLoaded', () => {

View File

@ -4,19 +4,16 @@
<meta charset="UTF-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<title>Novela — Statistics</title>
<link rel="icon" href="/static/favicon.ico" sizes="16x16"/>
<link rel="icon" type="image/png" sizes="32x32" href="/static/favicon-32.png"/>
<link rel="icon" type="image/png" sizes="256x256" href="/static/favicon-256.png"/>
<link rel="apple-touch-icon" sizes="180x180" href="/static/apple-touch-icon.png"/>
<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/theme.css"/>
<link rel="stylesheet" href="/static/sidebar.css"/>
<script src="https://cdn.jsdelivr.net/npm/chart.js@4/dist/chart.umd.min.js"></script>
<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); }
@ -164,18 +161,16 @@
</div>
</main>
<script src="/static/books.js"></script>
<script>
Chart.defaults.color = '#8a8278';
Chart.defaults.borderColor = '#2e2a24';
Chart.defaults.font.family = "'DM Mono', monospace";
Chart.defaults.font.size = 11;
const ACCENT = '#c8783a';
const ACCENT_A = 'rgba(200,120,58,0.15)';
const ACCENT = '#ffa20e';
const ACCENT_A = 'rgba(255,162,14,0.15)';
function esc(s) {
return String(s ?? '').replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
}
function fmtDate(iso) {
const d = new Date(iso);

View File

@ -248,6 +248,87 @@ Dropbox settings are managed via the web UI on `/backup`.
---
## Branding
Static assets in `static/`:
| File | Size | Purpose |
|------|------|---------|
| `logo.png` | 546×575, transparent | Sidebar wordmark (displayed at 26px height) |
| `favicon.ico` | 16×16 | Browser tab (legacy) |
| `favicon-32.png` | 32×32 | Browser tab (modern) |
| `favicon-256.png` | 256×256 | Pinned tabs / high-DPI |
| `apple-touch-icon.png` | 180×180 | iOS/iPadOS home screen icon |
All 15 page templates include:
```html
<link rel="icon" href="/static/favicon.ico" sizes="16x16"/>
<link rel="icon" type="image/png" sizes="32x32" href="/static/favicon-32.png"/>
<link rel="icon" type="image/png" sizes="256x256" href="/static/favicon-256.png"/>
<link rel="apple-touch-icon" sizes="180x180" href="/static/apple-touch-icon.png"/>
```
Sidebar logo: `logo.png` (26px, flex-aligned) next to the "No**vela**" wordmark ("No" in `--text`, "vela" in `--accent`).
`apple-touch-icon.png` uses `#0f0e0c` background (= `--bg`) with the orange N logo centered at 60% of canvas size.
---
## Shared CSS (`static/theme.css`)
Single `:root { }` block defining all global CSS custom properties. Loaded first on every page (`<link rel="stylesheet" href="/static/theme.css"/>`). No template defines its own global colours — only page-specific layout vars stay inline.
| Variable | Value | Role |
|---|---|---|
| `--bg` | `#0f0e0c` | Page background |
| `--surface` | `#1a1815` | Card/panel background |
| `--surface2` | `#221f1b` | Nested surface |
| `--border` | `#2e2a24` | Borders |
| `--accent` | `#ffa20e` | Orange highlight (logo colour) |
| `--accent2` | `#ffb840` | Lighter orange |
| `--text` | `#e8e2d9` | Body text |
| `--text-dim` | `#8a8278` | Muted text |
| `--text-faint` | `#4a453e` | Very muted text |
| `--success` | `#6baa6b` | Success state |
| `--warning` | `#c8a03a` | Warning state |
| `--error` | `#c85a3a` | Error state |
| `--radius` | `6px` | Border radius |
| `--sidebar` | `220px` | Sidebar width |
| `--mono` | `'DM Mono', monospace` | Monospace font stack |
| `--serif` | `'Libre Baskerville', Georgia, serif` | Serif font stack |
Page-specific overrides: `reader.html` (`--header-h`, `--footer-h`, `--content-w`); `backup.html` (`--ok`, `--warn`, `--err`); `editor.css` (`--danger`, `--header-h`, `--panel-w`).
## Shared JavaScript (`static/books.js`)
Loaded before any page-specific script on every page that needs book data or UI helpers.
| Function | Purpose |
|---|---|
| `esc(s)` | HTML-escape a string for safe insertion into markup |
| `strHash(s)` | Deterministic integer hash of a string (for colour selection) |
| `COVER_PALETTES` | Array of 8 `[bg, fg]` colour pairs for placeholder covers |
| `wrapText(ctx, text, x, y, maxW, lineH)` | Canvas word-wrap helper |
| `truncate(s, n)` | Truncate string with ellipsis |
| `makePlaceholderCover(canvas, title, author)` | Draw a generated book cover on a `<canvas>` |
| `_filenameBase(filename)` | Strip path and extension from a filename |
| `bookTitle(b)` | Return display title (falls back to filename parsing) |
| `bookAuthor(b)` | Return display author (falls back to filename parsing) |
| `tagValuesByType(b, type)` | Return tag strings of a given type from `b.tags` |
| `bookGenres(b)` | Tags of type `genre`; falls back to `subject` |
| `bookSubgenres(b)` | Tags of type `subgenre` |
| `bookPlainTags(b)` | Tags of type `tag` |
| `filterBooks(books, query)` | Filter book list by query across title, author, publisher, genre, sub-genre, tag |
| `setupSearchInput(inputId, clearId, onSearch)` | Wire input: show/hide clear button on input; call `onSearch(query)` on Enter |
## Shared JavaScript (`static/conversion.js`)
Loaded by `index.html` (Convert page) and `grabber.html` (Grabber page). Requires `books.js` for `esc()`.
| Function | Purpose |
|---|---|
| `addLog(msg, cls)` | Append a log line to `#log-lines` |
| `connectConversionStream(job_id)` | Open SSE stream `/events/{job_id}` and handle all conversion events: `status`, `meta`, `chapters`, `progress`, `warning`, `error`, `done` |
## UI Notes
- Library import accepts EPUB/PDF/CBR/CBZ.
- Home supports the same import formats.

View File

@ -1,12 +1,47 @@
# Develop Changelog
## 2026-03-29 (14)
## 2026-03-29 (10)
- Duplicates: fixed `updateCounts` crashing with a TypeError (`g.books.length` → `g.length`); the crash prevented `renderGrid` from running, so the duplicates view never rendered and the counter was stale
## 2026-03-29 (9)
- Duplicates: volume-aware duplicate detection — a book is only a duplicate when title + author + volume all match
- `_duplicateGroups` in `library.js` (Duplicates view): key now includes `series_index` when > 0, so different volumes of the same series are no longer grouped as duplicates
- `preload` in `grabber.py` (Grabber): when the scraper returns a `series_index_hint`, the DB lookup only flags a match when title + author + volume all match; falls back to title + author when no volume is known — same logic as the bulk importer
## 2026-03-29 (8)
- Shared code: eliminated all remaining duplication across templates and JS files
- CSS custom properties: extracted single `:root { }` block into `static/theme.css`; removed duplicate inline `:root` from all 15 templates and from `library.css`, `book.css``editor.css` keeps editor-specific vars (`--danger`, `--header-h`, `--panel-w`), `reader.html` keeps page-specific vars (`--header-h`, `--footer-h`, `--content-w`), `backup.html` keeps (`--ok`, `--warn`, `--err`)
- Cover helpers: moved `strHash`, `COVER_PALETTES`, `makePlaceholderCover`, `wrapText`, `truncate` from `library.js` and `book.js` into `books.js`; removed from `home.html` and `following.html`
- HTML escape: `esc()` added to `books.js`; removed from `library.js`, `editor.js`, and all 8 templates that defined it inline
- SSE/EventSource: extracted shared `connectConversionStream(job_id)` and `addLog()` into new `static/conversion.js`; both `index.html` and `grabber.html` now call the shared function (removed ~70 duplicate lines)
## 2026-03-29 (7)
- Search: extracted shared book helpers and search logic into `static/books.js`
- `_filenameBase`, `bookTitle`, `bookAuthor`, `tagValuesByType`, `bookGenres`, `bookSubgenres`, `bookPlainTags`, `filterBooks`, `setupSearchInput` moved from `library.js` and `home.html` to a single shared file
- Both pages now use identical search behaviour: Enter to search, × to clear
- Both pages now search across title, author, publisher, genre, sub-genre, and tag
## 2026-03-29 (6)
- Search: changed from search-as-you-type (250 ms debounce) to Enter-to-search on both Library and Home — prevents iPad keyboard from locking up on large collections
## 2026-03-29 (5)
- Accent colour updated to match logo orange (`#ffa20e`); secondary accent updated to `#ffb840`
- Applied across all CSS files, templates, and inline styles
## 2026-03-29 (4)
- Branding: added logo, favicon, and Apple touch icon to all pages
- Static assets: `logo.png` (sidebar), `favicon.ico` (16×16), `favicon-32.png` (32×32), `favicon-256.png` (256×256), `apple-touch-icon.png` (180×180 with `#0f0e0c` background)
- Favicon `<link>` tags added to all 15 templates
- Sidebar: image logo (`logo.png`) placed next to the existing "No**vela**" wordmark using flexbox
- `apple-touch-icon.png` uses dark `#0f0e0c` background — renders as a native-looking iOS home screen icon
## 2026-03-29 (3)
- Dockerfile: replaced `unrar-free` with proprietary `unrar` (RARLAB v6.2.6) from Debian non-free — fixes "Failed to read enough data" errors on RAR archives using newer compression methods
## 2026-03-29 (13)
## 2026-03-29 (2)
- CBR reader: detect archive format via magic bytes instead of file extension — `.cbr` files that are actually ZIP or 7-zip archives now open correctly; added `py7zr` dependency for 7-zip support
## 2026-03-29 (12)
## 2026-03-29 (1)
- Bulk Import: duplicate check now volume-aware — books with the same title+author but a different volume number (e.g. recoloured reprints) are no longer flagged as duplicates; `volume` is included in the API call and matched against `series_index` in the DB
## 2026-03-28 (11)

99
docs/changelog.md Normal file
View File

@ -0,0 +1,99 @@
# Changelog
## v0.1.1 — 2026-03-31
Bug fixes, volume-aware duplicate detection, shared code cleanup, and a new Changelog page.
### Bug fixes
- Duplicates view crashed on load due to a TypeError (`g.books.length` was undefined); counter was stale and the view never rendered
- Duplicate detection was too aggressive: different volumes of the same series (same title + author, different volume) were incorrectly grouped as duplicates — now keyed on title + author + volume
- Grabber preload: same volume-aware fix — only flags a duplicate when title, author, and volume all match; falls back to title + author when no volume is known
- Bulk Import duplicate check: different volumes of the same series are no longer flagged as duplicates
### Improvements
- Search changed from search-as-you-type (250 ms debounce) to Enter-to-search — prevents the iPad keyboard from locking up on large collections
- CBR reader: archive format now detected via magic bytes instead of file extension — `.cbr` files that are actually ZIP or 7-zip archives open correctly; added 7-zip support via `py7zr`
- Docker: replaced `unrar-free` with proprietary unrar (RARLAB v6.2.6) — fixes failures on RAR archives using newer compression methods
### New feature
- Changelog page (`/changelog`): structured release history with version, date, and categorised change lists
### Code quality
- Shared CSS (`theme.css`): single `:root` block with all global CSS custom properties; loaded on every page — no more duplicate inline `:root` blocks across templates
- Shared JS (`books.js`): book helpers (`bookTitle`, `bookAuthor`, `bookGenres`, `bookSubgenres`, `bookPlainTags`, `filterBooks`) and search input wiring extracted into one shared file
- Shared JS (`conversion.js`): SSE/EventSource logic (`connectConversionStream`, `addLog`) extracted from Convert and Grabber pages into one shared file
---
## v0.1.0 — 2026-03-29
First release of Novela: a self-hosted personal library for EPUB, PDF, CBR, and CBZ files.
### Library
- Grid and List view for all books and New books, with column visibility filter and persistent view mode
- Sidebar navigation: All books, Want to Read, New, Incomplete, Series, Authors, Publishers, Archived, Bookmarks, Rated, Duplicates, Statistics
- Sidebar counters for all sections, live-updated without page reload
- 15 star ratings stored in DB and written back to EPUB OPF / CBZ ComicInfo.xml
- Publication status: Complete, Ongoing, Temporary Hold, Long-Term Hold
- Status and want-to-read badges on grid covers, always readable regardless of cover colour
- Duplicate detection: groups books by title+author; counter in sidebar
- Incomplete view: all non-archived books where publication status is not Complete
- Rated view: non-archived books with a star rating, sorted by rating
- Bulk delete in All books List view with multi-select and Shift+click range selection
- Disk usage warning in sidebar (amber ≥ 85%, red ≥ 95% or low free space)
- Autocomplete for Author, Publisher, and Series in the book edit panel
- Series volume suffix support (e.g. "21a", "21b") and volume 0 for prequels/specials
- Cover upload for EPUB books; cover cache for fast subsequent loads
### Reader
- EPUB reader with chapter navigation, scroll progress, and bookmarks
- PDF reader with page-image rendering and page navigation
- CBR/CBZ reader with page-image rendering; format detection via magic bytes (supports ZIP, RAR, 7-zip archives)
- Reader text colour: 5 warm-tone presets, persisted per browser
- Content width slider (30100 vw), persisted per browser
- Bookmarks: save position with optional note; navigate back via sidebar or bookmark list
### Import & Convert
- Single-file import: drag-and-drop or file picker for EPUB, PDF, CBR, CBZ
- Bulk Import (`/bulk-import`): batch import with `%placeholder%` filename pattern parsing, shared metadata, live preview table, and duplicate detection
- Convert (`/convert`): scrape web fiction and convert to EPUB; warns if title+author already exists in library
- Grabber with credentials manager for site-specific login
### Book Builder
- Create EPUB books from scratch via a WYSIWYG editor (`/builder`)
- Chapters with contenteditable editing; toolbar: bold, italic, underline, blockquote, author note, scene break, normalize
- Autosave every 30 s and Ctrl+S; publish produces a standards-compliant EPUB 2.0 added directly to the library
### Following
- Following page (`/following`): track external author URLs
- Two tabs: Following (authors with URL set) and All Authors
- Inline URL editing with Enter/Escape support; Visit opens URL in new tab
- Sidebar counter shows number of followed authors
### Backup
- Dropbox backup with versioned snapshots and object-store deduplication
- OAuth2 refresh token flow (does not expire); legacy access token supported as fallback
- Configurable backup root, snapshot retention, and scheduled interval
- Live backup progress in sidebar (file count + phase); backup status dot with time-ago
- PostgreSQL dump included in each backup run
### Performance
- Library loads instantly for large collections: ETag `304 Not Modified`, `IntersectionObserver` lazy covers, single DOM pass rendering, `json_agg` SQL tag aggregation
- Fast-path `/api/library` (DB-only); full rescan only on demand
### Branding
- Favicon for browser tabs (16×16, 32×32, 256×256)
- Apple touch icon (180×180) with dark background for iOS home screen
- Logo in sidebar alongside the Novela wordmark

View File

@ -1 +1 @@
v0.1.1
v0.1.3