Compare commits
No commits in common. "fb8311fb3fa6fcb33fe9260c8af5286c39533280" and "b70379c5b952cbe7a9100aec422c53fd61eeed15" have entirely different histories.
fb8311fb3f
...
b70379c5b9
@ -3,14 +3,14 @@ set -euo pipefail
|
|||||||
|
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
# build-and-push.sh
|
# build-and-push.sh
|
||||||
# Location: repo root
|
# Location: repo root (e.g. /docker/develop/novela)
|
||||||
#
|
#
|
||||||
# Purpose:
|
# Purpose:
|
||||||
# - Automatic version bump:
|
# - Automatic version bump:
|
||||||
# 1 = patch, 2 = minor, 3 = major, t = test
|
# 1 = patch, 2 = minor, 3 = major, t = test
|
||||||
# - Test builds: only update :dev (no commit/tag)
|
# - Test builds: only update :dev (no commit/tag)
|
||||||
# - Release builds: update version.txt, commit, tag, push (to the current branch)
|
# - Release builds: update version.txt, commit, tag, push (to the current branch)
|
||||||
# - Build & push Docker images for each service under ./compose/*
|
# - Build & push Docker images for each service under ./containers/*
|
||||||
# - Preflight checks: Docker daemon up, logged in to registry, valid names/tags
|
# - Preflight checks: Docker daemon up, logged in to registry, valid names/tags
|
||||||
# - Summary: show all images + tags built and pushed
|
# - Summary: show all images + tags built and pushed
|
||||||
# - Branch visibility:
|
# - Branch visibility:
|
||||||
@ -120,7 +120,7 @@ if [[ ! -d ".git" ]]; then
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
if [[ ! -d "$COMPOSE_DIR" ]]; then
|
if [[ ! -d "$COMPOSE_DIR" ]]; then
|
||||||
echo "[ERROR] '$COMPOSE_DIR' directory missing. Expected ./compose/<service>/ with a Dockerfile."
|
echo "[ERROR] '$COMPOSE_DIR' directory missing. Expected ./containers/<service>/ with a Dockerfile."
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
|||||||
@ -1,134 +0,0 @@
|
|||||||
"""
|
|
||||||
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",
|
|
||||||
"1–5 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 (30–100 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",
|
|
||||||
],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
]
|
|
||||||
@ -11,7 +11,6 @@ from routers import (
|
|||||||
backup_router,
|
backup_router,
|
||||||
builder_router,
|
builder_router,
|
||||||
bulk_import_router,
|
bulk_import_router,
|
||||||
changelog_router,
|
|
||||||
editor_router,
|
editor_router,
|
||||||
following_router,
|
following_router,
|
||||||
grabber_router,
|
grabber_router,
|
||||||
@ -45,7 +44,6 @@ app.include_router(backup_router)
|
|||||||
app.include_router(builder_router)
|
app.include_router(builder_router)
|
||||||
app.include_router(bulk_import_router)
|
app.include_router(bulk_import_router)
|
||||||
app.include_router(following_router)
|
app.include_router(following_router)
|
||||||
app.include_router(changelog_router)
|
|
||||||
|
|
||||||
|
|
||||||
@app.get("/")
|
@app.get("/")
|
||||||
|
|||||||
@ -1,7 +1,6 @@
|
|||||||
from routers.backup import router as backup_router
|
from routers.backup import router as backup_router
|
||||||
from routers.builder import router as builder_router
|
from routers.builder import router as builder_router
|
||||||
from routers.bulk_import import router as bulk_import_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.editor import router as editor_router
|
||||||
from routers.following import router as following_router
|
from routers.following import router as following_router
|
||||||
from routers.grabber import router as grabber_router
|
from routers.grabber import router as grabber_router
|
||||||
@ -19,5 +18,4 @@ __all__ = [
|
|||||||
"builder_router",
|
"builder_router",
|
||||||
"bulk_import_router",
|
"bulk_import_router",
|
||||||
"following_router",
|
"following_router",
|
||||||
"changelog_router",
|
|
||||||
]
|
]
|
||||||
|
|||||||
@ -1,17 +0,0 @@
|
|||||||
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,
|
|
||||||
})
|
|
||||||
@ -224,25 +224,15 @@ async def preload(request: Request):
|
|||||||
with get_db_conn() as conn:
|
with get_db_conn() as conn:
|
||||||
with conn.cursor() as cur:
|
with conn.cursor() as cur:
|
||||||
cur.execute(
|
cur.execute(
|
||||||
"""SELECT filename, title, author, series_index FROM library
|
"""SELECT filename, title, author FROM library
|
||||||
WHERE LOWER(TRIM(title)) = LOWER(TRIM(%s))
|
WHERE LOWER(TRIM(title)) = LOWER(TRIM(%s))
|
||||||
AND LOWER(TRIM(author)) = LOWER(TRIM(%s))""",
|
AND LOWER(TRIM(author)) = LOWER(TRIM(%s))""",
|
||||||
(title, author),
|
(title, author),
|
||||||
)
|
)
|
||||||
rows = cur.fetchall()
|
existing_books = [
|
||||||
|
{"filename": r[0], "title": r[1] or "", "author": r[2] or ""}
|
||||||
if hint:
|
for r in cur.fetchall()
|
||||||
# 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 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 {
|
return {
|
||||||
"title": title,
|
"title": title,
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 8.8 KiB |
@ -1,5 +1,13 @@
|
|||||||
/* ── Novela — Book detail page styles ─────────────────────────────────── */
|
/* ── 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; }
|
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||||
html, body { height: 100%; background: var(--bg); color: var(--text); font-family: var(--serif); }
|
html, body { height: 100%; background: var(--bg); color: var(--text); font-family: var(--serif); }
|
||||||
|
|
||||||
@ -118,7 +126,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-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-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-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(255,162,14,0.12); color: #ffa20e; border: 1px solid rgba(255,162,14,0.25); }
|
.status-long-term-hold { background: rgba(200,120,58,0.12); color: #c8783a; border: 1px solid rgba(200,120,58,0.25); }
|
||||||
|
|
||||||
/* Progress */
|
/* Progress */
|
||||||
.progress-section { margin-bottom: 1.25rem; }
|
.progress-section { margin-bottom: 1.25rem; }
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
/* ── Novela — Book detail page script ─────────────────────────────────── */
|
/* ── Novela — Book detail page script ─────────────────────────────────── */
|
||||||
/* Requires: books.js loaded first; BOOK global defined inline */
|
/* Requires: BOOK global defined inline before this script is loaded */
|
||||||
|
|
||||||
const { filename, title, author } = BOOK;
|
const { filename, title, author } = BOOK;
|
||||||
|
|
||||||
@ -8,6 +8,43 @@ const { filename, title, author } = BOOK;
|
|||||||
const canvas = document.getElementById('cover-canvas');
|
const canvas = document.getElementById('cover-canvas');
|
||||||
canvas.width = 180;
|
canvas.width = 180;
|
||||||
canvas.height = 270;
|
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));
|
requestAnimationFrame(() => makePlaceholderCover(canvas, title, author));
|
||||||
if (BOOK.has_cover) {
|
if (BOOK.has_cover) {
|
||||||
const img = document.getElementById('cover-img');
|
const img = document.getElementById('cover-img');
|
||||||
|
|||||||
@ -1,127 +0,0 @@
|
|||||||
// ── Novela — shared utilities ────────────────────────────────────────────────
|
|
||||||
|
|
||||||
// HTML-escape a string for safe insertion into markup.
|
|
||||||
function esc(s) {
|
|
||||||
return String(s ?? '').replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── 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()); }
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@ -1,84 +0,0 @@
|
|||||||
// ── 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();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@ -1,5 +1,10 @@
|
|||||||
:root {
|
:root {
|
||||||
--danger: #c85a5a;
|
--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;
|
||||||
--header-h: 50px;
|
--header-h: 50px;
|
||||||
--panel-w: 240px;
|
--panel-w: 240px;
|
||||||
}
|
}
|
||||||
@ -57,7 +62,7 @@ html, body { height: 100%; background: var(--bg); color: var(--text); font-famil
|
|||||||
font-family: var(--mono); font-size: 0.72rem; color: var(--accent);
|
font-family: var(--mono); font-size: 0.72rem; color: var(--accent);
|
||||||
cursor: pointer; transition: background 0.12s;
|
cursor: pointer; transition: background 0.12s;
|
||||||
}
|
}
|
||||||
.btn-save-all:hover { background: rgba(255,162,14,0.12); }
|
.btn-save-all:hover { background: rgba(200,120,58,0.12); }
|
||||||
|
|
||||||
.btn-break {
|
.btn-break {
|
||||||
display: flex; align-items: center; gap: 0.35rem;
|
display: flex; align-items: center; gap: 0.35rem;
|
||||||
|
|||||||
@ -424,3 +424,7 @@ function setStatus(cls, text) {
|
|||||||
el.className = 'save-status' + (cls ? ' ' + cls : '');
|
el.className = 'save-status' + (cls ? ' ' + cls : '');
|
||||||
el.textContent = text;
|
el.textContent = text;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function esc(s) {
|
||||||
|
return String(s ?? '').replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
||||||
|
}
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 46 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 1.8 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 762 B |
@ -1,5 +1,24 @@
|
|||||||
/* ── Novela — Library page styles ─────────────────────────────────────── */
|
/* ── 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; }
|
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||||
|
|
||||||
html, body {
|
html, body {
|
||||||
@ -64,7 +83,7 @@ html, body {
|
|||||||
.import-dropzone:hover { border-color: var(--accent); }
|
.import-dropzone:hover { border-color: var(--accent); }
|
||||||
.import-dropzone.dragover {
|
.import-dropzone.dragover {
|
||||||
border-color: var(--accent2);
|
border-color: var(--accent2);
|
||||||
background: rgba(255, 162, 14, 0.12);
|
background: rgba(200, 120, 58, 0.12);
|
||||||
}
|
}
|
||||||
.import-dropzone.uploading {
|
.import-dropzone.uploading {
|
||||||
opacity: 0.8;
|
opacity: 0.8;
|
||||||
@ -138,7 +157,7 @@ html, body {
|
|||||||
.badge-complete { color: #6baa6b; }
|
.badge-complete { color: #6baa6b; }
|
||||||
.badge-ongoing { color: #4a90b8; }
|
.badge-ongoing { color: #4a90b8; }
|
||||||
.badge-temporary-hold { color: #c8a03a; }
|
.badge-temporary-hold { color: #c8a03a; }
|
||||||
.badge-long-term-hold { color: #ffa20e; }
|
.badge-long-term-hold { color: #c8783a; }
|
||||||
|
|
||||||
/* Star: want-to-read top-left */
|
/* Star: want-to-read top-left */
|
||||||
.btn-star {
|
.btn-star {
|
||||||
@ -198,7 +217,7 @@ html, body {
|
|||||||
/* Read count pill */
|
/* Read count pill */
|
||||||
.read-pill {
|
.read-pill {
|
||||||
position: absolute; bottom: 0.35rem; right: 0.35rem;
|
position: absolute; bottom: 0.35rem; right: 0.35rem;
|
||||||
background: rgba(255,162,14,0.88); color: #0f0e0c;
|
background: rgba(200,120,58,0.88); color: #0f0e0c;
|
||||||
font-family: var(--mono); font-size: 0.6rem; font-weight: 500;
|
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;
|
padding: 0.1rem 0.38rem; border-radius: 3px; z-index: 2; pointer-events: none;
|
||||||
}
|
}
|
||||||
@ -207,7 +226,7 @@ html, body {
|
|||||||
.progress-mini {
|
.progress-mini {
|
||||||
position: absolute; bottom: 0; left: 0; right: 0;
|
position: absolute; bottom: 0; left: 0; right: 0;
|
||||||
height: 3px; z-index: 2; pointer-events: none;
|
height: 3px; z-index: 2; pointer-events: none;
|
||||||
background: rgba(255,162,14,0.25);
|
background: rgba(200,120,58,0.25);
|
||||||
}
|
}
|
||||||
.progress-mini-fill { height: 100%; background: var(--accent); }
|
.progress-mini-fill { height: 100%; background: var(--accent); }
|
||||||
|
|
||||||
@ -433,13 +452,13 @@ html, body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.publisher-missing-wrap {
|
.publisher-missing-wrap {
|
||||||
border: 1px solid rgba(255, 162, 14, 0.28);
|
border: 1px solid rgba(200, 120, 58, 0.28);
|
||||||
border-radius: var(--radius);
|
border-radius: var(--radius);
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.publisher-missing-item {
|
.publisher-missing-item {
|
||||||
background: rgba(255, 162, 14, 0.08);
|
background: rgba(200, 120, 58, 0.08);
|
||||||
}
|
}
|
||||||
|
|
||||||
.publisher-divider {
|
.publisher-divider {
|
||||||
@ -523,8 +542,8 @@ html, body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.btn.btn-view.active {
|
.btn.btn-view.active {
|
||||||
border-color: rgba(255, 162, 14, 0.45);
|
border-color: rgba(200, 120, 58, 0.45);
|
||||||
background: rgba(255, 162, 14, 0.16);
|
background: rgba(200, 120, 58, 0.16);
|
||||||
color: var(--accent2);
|
color: var(--accent2);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -644,7 +663,7 @@ html, body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.new-list-table tbody tr:hover {
|
.new-list-table tbody tr:hover {
|
||||||
background: rgba(255, 162, 14, 0.08);
|
background: rgba(200, 120, 58, 0.08);
|
||||||
}
|
}
|
||||||
|
|
||||||
.new-col-select {
|
.new-col-select {
|
||||||
|
|||||||
@ -53,6 +53,72 @@ let allVisibleColumns = loadAllVisibleColumns();
|
|||||||
let allSelectedFilenames = new Set();
|
let allSelectedFilenames = new Set();
|
||||||
let allLastToggledIndex = null;
|
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 ───────────────────────────────────────────────────────────
|
// ── Data loading ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
let _libraryETag = null;
|
let _libraryETag = null;
|
||||||
@ -116,11 +182,25 @@ function updateCounts() {
|
|||||||
const ratedEl = document.getElementById('count-rated');
|
const ratedEl = document.getElementById('count-rated');
|
||||||
if (ratedEl) ratedEl.textContent = ratedCount || '';
|
if (ratedEl) ratedEl.textContent = ratedCount || '';
|
||||||
const dupGroups = _duplicateGroups(active);
|
const dupGroups = _duplicateGroups(active);
|
||||||
const dupCount = dupGroups.reduce((s, g) => s + g.length, 0);
|
const dupCount = dupGroups.reduce((s, g) => s + g.books.length, 0);
|
||||||
const dupEl = document.getElementById('count-duplicates');
|
const dupEl = document.getElementById('count-duplicates');
|
||||||
if (dupEl) dupEl.textContent = dupCount || '';
|
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) {
|
function normalizePublisherName(value) {
|
||||||
const v = (value || '').trim();
|
const v = (value || '').trim();
|
||||||
@ -419,6 +499,27 @@ 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) {
|
function formatUpdated(iso) {
|
||||||
if (!iso) return '';
|
if (!iso) return '';
|
||||||
@ -1339,7 +1440,15 @@ function renderGenreView(tag) {
|
|||||||
|
|
||||||
function renderSearchResults(query) {
|
function renderSearchResults(query) {
|
||||||
if (!query) { renderBooksGrid(activeBooks()); return; }
|
if (!query) { renderBooksGrid(activeBooks()); return; }
|
||||||
renderBooksGrid(filterBooks(activeBooks(), query));
|
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);
|
||||||
}
|
}
|
||||||
|
|
||||||
function clearSearch() {
|
function clearSearch() {
|
||||||
@ -1440,12 +1549,7 @@ function renderRatedView() {
|
|||||||
function _duplicateGroups(books) {
|
function _duplicateGroups(books) {
|
||||||
const map = new Map();
|
const map = new Map();
|
||||||
books.forEach(b => {
|
books.forEach(b => {
|
||||||
const title = bookTitle(b).trim().toLowerCase();
|
const key = (bookTitle(b).trim().toLowerCase()) + '|' + (bookAuthor(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, []);
|
if (!map.has(key)) map.set(key, []);
|
||||||
map.get(key).push(b);
|
map.get(key).push(b);
|
||||||
});
|
});
|
||||||
@ -1747,29 +1851,35 @@ async function uploadImportedFiles(files) {
|
|||||||
|
|
||||||
// ── Utilities ──────────────────────────────────────────────────────────────
|
// ── Utilities ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function esc(s) {
|
||||||
|
return String(s ?? '').replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
||||||
|
}
|
||||||
function jsEsc(s) { return String(s ?? '').replace(/\\/g, '\\\\').replace(/'/g, "\\'"); }
|
function jsEsc(s) { return String(s ?? '').replace(/\\/g, '\\\\').replace(/'/g, "\\'"); }
|
||||||
function cssId(filename) { return filename.replace(/[^a-zA-Z0-9_-]/g, '_'); }
|
function cssId(filename) { return filename.replace(/[^a-zA-Z0-9_-]/g, '_'); }
|
||||||
|
|
||||||
// ── Search input ───────────────────────────────────────────────────────────
|
// ── Search input ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
function triggerSearch() {
|
let searchTimer = null;
|
||||||
const q = document.getElementById('search-input').value.trim();
|
document.getElementById('search-input').addEventListener('input', function() {
|
||||||
if (q) {
|
const q = this.value.trim();
|
||||||
currentView = 'search';
|
document.getElementById('search-clear').style.display = q ? '' : 'none';
|
||||||
currentParam = q;
|
clearTimeout(searchTimer);
|
||||||
['nav-all','nav-wtr','nav-new','nav-incomplete','nav-series','nav-authors','nav-publishers','nav-archived','nav-bookmarks','nav-rated','nav-duplicates'].forEach(id => {
|
searchTimer = setTimeout(() => {
|
||||||
const el = document.getElementById(id);
|
if (q) {
|
||||||
if (el) el.classList.remove('active');
|
currentView = 'search';
|
||||||
});
|
currentParam = q;
|
||||||
document.getElementById('section-title').textContent = `Search: "${q}"`;
|
['nav-all','nav-wtr','nav-new','nav-incomplete','nav-series','nav-authors','nav-publishers','nav-archived','nav-bookmarks','nav-rated','nav-duplicates'].forEach(id => {
|
||||||
document.getElementById('back-btn').style.display = 'none';
|
const el = document.getElementById(id);
|
||||||
renderGrid();
|
if (el) el.classList.remove('active');
|
||||||
} else {
|
});
|
||||||
switchView('all');
|
document.getElementById('section-title').textContent = `Search: "${q}"`;
|
||||||
}
|
document.getElementById('back-btn').style.display = 'none';
|
||||||
}
|
renderGrid();
|
||||||
|
} else {
|
||||||
setupSearchInput('search-input', 'search-clear', () => triggerSearch());
|
switchView('all');
|
||||||
|
}
|
||||||
|
}, 250);
|
||||||
|
});
|
||||||
|
|
||||||
// ── Init ───────────────────────────────────────────────────────────────────
|
// ── Init ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 429 KiB |
@ -24,16 +24,6 @@ html {
|
|||||||
border-bottom: 1px solid var(--border);
|
border-bottom: 1px solid var(--border);
|
||||||
margin-bottom: 1rem;
|
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 {
|
.sidebar-logo h1 {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font-size: 1.25rem;
|
font-size: 1.25rem;
|
||||||
@ -41,6 +31,13 @@ html {
|
|||||||
letter-spacing: -0.02em;
|
letter-spacing: -0.02em;
|
||||||
}
|
}
|
||||||
.sidebar-logo h1 span { color: var(--accent); }
|
.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 {
|
.sidebar-section-label {
|
||||||
font-family: var(--mono);
|
font-family: var(--mono);
|
||||||
|
|||||||
@ -1,20 +0,0 @@
|
|||||||
/* ── 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;
|
|
||||||
}
|
|
||||||
@ -9,10 +9,7 @@
|
|||||||
|
|
||||||
<aside class="sidebar" id="sidebar">
|
<aside class="sidebar" id="sidebar">
|
||||||
<div class="sidebar-logo">
|
<div class="sidebar-logo">
|
||||||
<a href="/home" style="text-decoration:none;color:inherit">
|
<a href="/home" style="text-decoration:none;color:inherit"><h1>No<span>vela</span></h1></a>
|
||||||
<img src="/static/logo.png" alt="N" class="sidebar-logo-img"/>
|
|
||||||
<h1>No<span>vela</span></h1>
|
|
||||||
</a>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<ul class="sidebar-nav">
|
<ul class="sidebar-nav">
|
||||||
@ -247,18 +244,6 @@
|
|||||||
Settings
|
Settings
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</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>
|
</ul>
|
||||||
|
|
||||||
<div class="sidebar-bottom">
|
<div class="sidebar-bottom">
|
||||||
|
|||||||
@ -4,17 +4,17 @@
|
|||||||
<meta charset="UTF-8"/>
|
<meta charset="UTF-8"/>
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
|
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
|
||||||
<title>Novela - Backup</title>
|
<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 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 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/sidebar.css"/>
|
||||||
<style>
|
<style>
|
||||||
:root {
|
:root {
|
||||||
|
--bg: #0f0e0c; --surface: #1a1815; --surface2: #221f1b;
|
||||||
|
--border: #2e2a24; --accent: #c8783a;
|
||||||
|
--text: #e8e2d9; --text-dim: #8a8278;
|
||||||
--ok: #7fbe7f; --warn: #d2b063; --err: #d0674c;
|
--ok: #7fbe7f; --warn: #d2b063; --err: #d0674c;
|
||||||
|
--sidebar: 220px; --radius: 8px;
|
||||||
|
--mono: 'DM Mono', monospace; --serif: 'Libre Baskerville', Georgia, serif;
|
||||||
}
|
}
|
||||||
* { box-sizing: border-box; }
|
* { box-sizing: border-box; }
|
||||||
html, body { margin: 0; min-height: 100%; background: var(--bg); color: var(--text); font-family: var(--serif); }
|
html, body { margin: 0; min-height: 100%; background: var(--bg); color: var(--text); font-family: var(--serif); }
|
||||||
@ -87,7 +87,7 @@
|
|||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
.btn:hover { border-color: var(--accent); }
|
.btn:hover { border-color: var(--accent); }
|
||||||
.btn.primary { border-color: rgba(255,162,14,0.45); background: rgba(255,162,14,0.12); }
|
.btn.primary { border-color: rgba(200,120,58,0.45); background: rgba(200,120,58,0.12); }
|
||||||
.btn:disabled { opacity: 0.5; cursor: not-allowed; }
|
.btn:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||||
|
|
||||||
.field-label {
|
.field-label {
|
||||||
@ -238,8 +238,11 @@
|
|||||||
</section>
|
</section>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<script src="/static/books.js"></script>
|
|
||||||
<script>
|
<script>
|
||||||
|
function esc(v) {
|
||||||
|
return String(v ?? '').replace(/[&<>"']/g, (c) => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));
|
||||||
|
}
|
||||||
|
|
||||||
function rowHtml(k, v) {
|
function rowHtml(k, v) {
|
||||||
return `<div class="row"><div class="k">${esc(k)}</div><div class="v">${esc(v)}</div></div>`;
|
return `<div class="row"><div class="k">${esc(k)}</div><div class="v">${esc(v)}</div></div>`;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -4,13 +4,8 @@
|
|||||||
<meta charset="UTF-8"/>
|
<meta charset="UTF-8"/>
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
|
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
|
||||||
<title>Novela — {{ title or filename }}</title>
|
<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 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 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/sidebar.css"/>
|
||||||
<link rel="stylesheet" href="/static/book.css"/>
|
<link rel="stylesheet" href="/static/book.css"/>
|
||||||
</head>
|
</head>
|
||||||
@ -349,7 +344,6 @@
|
|||||||
rating: {{ rating or 0 }},
|
rating: {{ rating or 0 }},
|
||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
<script src="/static/books.js"></script>
|
|
||||||
<script src="/static/book.js"></script>
|
<script src="/static/book.js"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@ -4,11 +4,6 @@
|
|||||||
<meta charset="UTF-8"/>
|
<meta charset="UTF-8"/>
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
|
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
|
||||||
<title>Novela — Book Builder{% if view == 'editor' %}: {{ draft.title }}{% endif %}</title>
|
<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/library.css"/>
|
||||||
<link rel="stylesheet" href="/static/sidebar.css"/>
|
<link rel="stylesheet" href="/static/sidebar.css"/>
|
||||||
<link rel="stylesheet" href="/static/builder.css"/>
|
<link rel="stylesheet" href="/static/builder.css"/>
|
||||||
|
|||||||
@ -4,15 +4,28 @@
|
|||||||
<meta charset="UTF-8"/>
|
<meta charset="UTF-8"/>
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
|
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
|
||||||
<title>Novela – Bulk Import</title>
|
<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 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 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/sidebar.css"/>
|
||||||
<style>
|
<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; }
|
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||||
html, body { height: 100%; }
|
html, body { height: 100%; }
|
||||||
body { background: var(--bg); color: var(--text); font-family: var(--serif); }
|
body { background: var(--bg); color: var(--text); font-family: var(--serif); }
|
||||||
@ -168,7 +181,7 @@
|
|||||||
}
|
}
|
||||||
tbody tr { border-bottom: 1px solid var(--border); }
|
tbody tr { border-bottom: 1px solid var(--border); }
|
||||||
tbody tr:last-child { border-bottom: none; }
|
tbody tr:last-child { border-bottom: none; }
|
||||||
tbody tr:hover { background: rgba(255,162,14,0.04); }
|
tbody tr:hover { background: rgba(200,120,58,0.04); }
|
||||||
tbody tr.row-warn { background: rgba(200,160,58,0.06); }
|
tbody tr.row-warn { background: rgba(200,160,58,0.06); }
|
||||||
tbody tr.row-warn:hover { background: rgba(200,160,58,0.10); }
|
tbody tr.row-warn:hover { background: rgba(200,160,58,0.10); }
|
||||||
td {
|
td {
|
||||||
@ -178,7 +191,7 @@
|
|||||||
td.td-filename { color: var(--text-dim); max-width: 200px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
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"] { outline: none; cursor: text; min-width: 60px; }
|
||||||
td[contenteditable="true"]:focus {
|
td[contenteditable="true"]:focus {
|
||||||
background: rgba(255,162,14,0.08);
|
background: rgba(200,120,58,0.08);
|
||||||
box-shadow: inset 0 0 0 1px var(--accent);
|
box-shadow: inset 0 0 0 1px var(--accent);
|
||||||
}
|
}
|
||||||
td[contenteditable="true"]:empty::before {
|
td[contenteditable="true"]:empty::before {
|
||||||
@ -428,7 +441,6 @@
|
|||||||
|
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<script src="/static/books.js"></script>
|
|
||||||
<script>
|
<script>
|
||||||
// ── State ──────────────────────────────────────────────────────────────────
|
// ── State ──────────────────────────────────────────────────────────────────
|
||||||
const PLACEHOLDER_META = [
|
const PLACEHOLDER_META = [
|
||||||
@ -861,6 +873,10 @@
|
|||||||
window.location.href = '/library#new';
|
window.location.href = '/library#new';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function esc(s) {
|
||||||
|
return String(s ?? '')
|
||||||
|
.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
||||||
|
}
|
||||||
|
|
||||||
// ── TextSuggest ────────────────────────────────────────────────────────────
|
// ── TextSuggest ────────────────────────────────────────────────────────────
|
||||||
class TextSuggest {
|
class TextSuggest {
|
||||||
|
|||||||
@ -1,130 +0,0 @@
|
|||||||
<!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>
|
|
||||||
@ -4,15 +4,29 @@
|
|||||||
<meta charset="UTF-8"/>
|
<meta charset="UTF-8"/>
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
|
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
|
||||||
<title>Novela — Credentials</title>
|
<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 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 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/sidebar.css"/>
|
||||||
<style>
|
<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; }
|
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||||
|
|
||||||
html, body { height: 100%; }
|
html, body { height: 100%; }
|
||||||
@ -281,7 +295,6 @@
|
|||||||
|
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<script src="/static/books.js"></script>
|
|
||||||
<script>
|
<script>
|
||||||
let allCredentials = {};
|
let allCredentials = {};
|
||||||
|
|
||||||
|
|||||||
@ -4,15 +4,29 @@
|
|||||||
<meta charset="UTF-8"/>
|
<meta charset="UTF-8"/>
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
|
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
|
||||||
<title>Novela — Debug</title>
|
<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 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 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/sidebar.css"/>
|
||||||
<style>
|
<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; }
|
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||||
|
|
||||||
html, body { height: 100%; }
|
html, body { height: 100%; }
|
||||||
@ -164,7 +178,6 @@
|
|||||||
|
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<script src="/static/books.js"></script>
|
|
||||||
<script>
|
<script>
|
||||||
async function runInspect() {
|
async function runInspect() {
|
||||||
const url = document.getElementById('url').value.trim();
|
const url = document.getElementById('url').value.trim();
|
||||||
@ -213,6 +226,10 @@
|
|||||||
toggle.textContent = collapsed ? '▼ expand' : '▲ collapse';
|
toggle.textContent = collapsed ? '▼ expand' : '▲ collapse';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function esc(s) {
|
||||||
|
return String(s ?? '')
|
||||||
|
.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
||||||
|
}
|
||||||
|
|
||||||
function metaRow(label, value) {
|
function metaRow(label, value) {
|
||||||
return `<div class="meta-row"><span class="meta-label">${label}</span><span class="meta-value">${value}</span></div>`;
|
return `<div class="meta-row"><span class="meta-label">${label}</span><span class="meta-value">${value}</span></div>`;
|
||||||
|
|||||||
@ -4,13 +4,8 @@
|
|||||||
<meta charset="UTF-8"/>
|
<meta charset="UTF-8"/>
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
|
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
|
||||||
<title>Novela — Edit {{ title or filename }}</title>
|
<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 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 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"/>
|
<link rel="stylesheet" href="/static/editor.css"/>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
@ -97,7 +92,6 @@
|
|||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
<script src="https://cdn.jsdelivr.net/npm/monaco-editor@0.45.0/min/vs/loader.js"></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>
|
<script src="/static/editor.js"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@ -4,15 +4,18 @@
|
|||||||
<meta charset="UTF-8"/>
|
<meta charset="UTF-8"/>
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
|
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
|
||||||
<title>Novela — Following</title>
|
<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 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 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/sidebar.css"/>
|
||||||
<style>
|
<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; }
|
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||||
html, body { height: 100%; background: var(--bg); color: var(--text); font-family: var(--serif); }
|
html, body { height: 100%; background: var(--bg); color: var(--text); font-family: var(--serif); }
|
||||||
|
|
||||||
@ -134,8 +137,20 @@
|
|||||||
</div>
|
</div>
|
||||||
<div id="author-list"><div class="loading">Loading…</div></div>
|
<div id="author-list"><div class="loading">Loading…</div></div>
|
||||||
</main>
|
</main>
|
||||||
<script src="/static/books.js"></script>
|
|
||||||
<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,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
||||||
|
}
|
||||||
function timeAgo(isoStr) {
|
function timeAgo(isoStr) {
|
||||||
if (!isoStr) return '';
|
if (!isoStr) return '';
|
||||||
const s = /[Zz+\-]\d*$/.test(isoStr.trim()) ? isoStr : isoStr + 'Z';
|
const s = /[Zz+\-]\d*$/.test(isoStr.trim()) ? isoStr : isoStr + 'Z';
|
||||||
|
|||||||
@ -4,15 +4,29 @@
|
|||||||
<meta charset="UTF-8"/>
|
<meta charset="UTF-8"/>
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
|
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
|
||||||
<title>Novela</title>
|
<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 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 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/sidebar.css"/>
|
||||||
<style>
|
<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; }
|
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||||
|
|
||||||
html, body { height: 100%; }
|
html, body { height: 100%; }
|
||||||
@ -310,8 +324,6 @@
|
|||||||
|
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<script src="/static/books.js"></script>
|
|
||||||
<script src="/static/conversion.js"></script>
|
|
||||||
<script>
|
<script>
|
||||||
let currentUrl = '';
|
let currentUrl = '';
|
||||||
let coverB64 = null;
|
let coverB64 = null;
|
||||||
@ -475,7 +487,85 @@
|
|||||||
document.getElementById('convert-label').textContent = 'Convert';
|
document.getElementById('convert-label').textContent = 'Convert';
|
||||||
document.getElementById('convert-spinner').style.display = 'none';
|
document.getElementById('convert-spinner').style.display = 'none';
|
||||||
|
|
||||||
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();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
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 clearDupWarning() {
|
function clearDupWarning() {
|
||||||
@ -494,6 +584,10 @@
|
|||||||
el.classList.add('visible');
|
el.classList.add('visible');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function esc(s) {
|
||||||
|
return String(s ?? '')
|
||||||
|
.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@ -4,15 +4,18 @@
|
|||||||
<meta charset="UTF-8"/>
|
<meta charset="UTF-8"/>
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
|
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
|
||||||
<title>Novela — Home</title>
|
<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 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 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/sidebar.css"/>
|
||||||
<style>
|
<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; }
|
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||||
html, body { height: 100%; background: var(--bg); color: var(--text); font-family: var(--serif); }
|
html, body { height: 100%; background: var(--bg); color: var(--text); font-family: var(--serif); }
|
||||||
|
|
||||||
@ -63,7 +66,7 @@
|
|||||||
.import-dropzone:hover { border-color: var(--accent); }
|
.import-dropzone:hover { border-color: var(--accent); }
|
||||||
.import-dropzone.dragover {
|
.import-dropzone.dragover {
|
||||||
border-color: var(--accent2);
|
border-color: var(--accent2);
|
||||||
background: rgba(255, 162, 14, 0.12);
|
background: rgba(200, 120, 58, 0.12);
|
||||||
}
|
}
|
||||||
.import-dropzone.uploading {
|
.import-dropzone.uploading {
|
||||||
opacity: 0.8;
|
opacity: 0.8;
|
||||||
@ -127,7 +130,7 @@
|
|||||||
font-family: var(--mono); font-size: 0.6rem; color: var(--text-dim);
|
font-family: var(--mono); font-size: 0.6rem; color: var(--text-dim);
|
||||||
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
|
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
|
||||||
}
|
}
|
||||||
.h-progress-bar { height: 3px; background: rgba(255,162,14,0.2); border-radius: 2px; margin-bottom: 0.25rem; }
|
.h-progress-bar { height: 3px; background: rgba(200,120,58,0.2); border-radius: 2px; margin-bottom: 0.25rem; }
|
||||||
.h-progress-fill { height: 100%; background: var(--accent); border-radius: 2px; }
|
.h-progress-fill { height: 100%; background: var(--accent); border-radius: 2px; }
|
||||||
.h-pct { font-family: var(--mono); font-size: 0.6rem; color: var(--text-dim); }
|
.h-pct { font-family: var(--mono); font-size: 0.6rem; color: var(--text-dim); }
|
||||||
|
|
||||||
@ -160,7 +163,7 @@
|
|||||||
.progress-mini {
|
.progress-mini {
|
||||||
position: absolute; bottom: 0; left: 0; right: 0;
|
position: absolute; bottom: 0; left: 0; right: 0;
|
||||||
height: 3px; z-index: 2; pointer-events: none;
|
height: 3px; z-index: 2; pointer-events: none;
|
||||||
background: rgba(255,162,14,0.25);
|
background: rgba(200,120,58,0.25);
|
||||||
}
|
}
|
||||||
.progress-mini-fill { height: 100%; background: var(--accent); }
|
.progress-mini-fill { height: 100%; background: var(--accent); }
|
||||||
.book-info { padding: 0.5rem 0.2rem 0; }
|
.book-info { padding: 0.5rem 0.2rem 0; }
|
||||||
@ -284,7 +287,6 @@
|
|||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<script src="/static/books.js"></script>
|
|
||||||
<script>
|
<script>
|
||||||
let data = { continue_reading: [], shorts_unread: [], novels_unread: [], shorts_read: [], novels_read: [] };
|
let data = { continue_reading: [], shorts_unread: [], novels_unread: [], shorts_read: [], novels_read: [] };
|
||||||
let currentView = 'home';
|
let currentView = 'home';
|
||||||
@ -293,6 +295,45 @@
|
|||||||
let allBooks = [];
|
let allBooks = [];
|
||||||
const IMPORT_EXTENSIONS = ['.epub', '.pdf', '.cbr', '.cbz'];
|
const IMPORT_EXTENSIONS = ['.epub', '.pdf', '.cbr', '.cbz'];
|
||||||
|
|
||||||
|
function strHash(s) {
|
||||||
|
let h = 0;
|
||||||
|
for (let i = 0; i < s.length; i++) h = (Math.imul(31, h) + s.charCodeAt(i)) | 0;
|
||||||
|
return Math.abs(h);
|
||||||
|
}
|
||||||
|
const PALETTES = [
|
||||||
|
['#1a2a3a','#4a8caa'],['#2a1a1a','#aa4a4a'],['#1a2a1a','#4aaa6a'],['#2a1a2a','#8a4aaa'],
|
||||||
|
['#2a2a1a','#aaa04a'],['#1a2a2a','#4aaa9a'],['#2a1a14','#c8783a'],['#141a2a','#5a78c8'],
|
||||||
|
];
|
||||||
|
|
||||||
|
function makePlaceholder(canvas, title, author) {
|
||||||
|
const w = canvas.width = canvas.offsetWidth || 150;
|
||||||
|
const h = canvas.height = canvas.offsetHeight || 225;
|
||||||
|
const ctx = canvas.getContext('2d');
|
||||||
|
const [bg, fg] = PALETTES[strHash(title) % PALETTES.length];
|
||||||
|
ctx.fillStyle = bg; ctx.fillRect(0, 0, w, h);
|
||||||
|
ctx.fillStyle = fg; ctx.globalAlpha = 0.15; ctx.fillRect(0, 0, w, h * 0.08); ctx.globalAlpha = 1;
|
||||||
|
ctx.fillStyle = fg; ctx.fillRect(w * 0.12, h * 0.12, w * 0.04, h * 0.55);
|
||||||
|
ctx.fillStyle = '#e8e2d9';
|
||||||
|
ctx.font = `bold ${Math.round(w * 0.105)}px 'Libre Baskerville', Georgia, serif`;
|
||||||
|
ctx.textAlign = 'center';
|
||||||
|
wrapText(ctx, title, w * 0.55, h * 0.28, w * 0.72, Math.round(w * 0.12));
|
||||||
|
ctx.fillStyle = fg; ctx.font = `${Math.round(w * 0.075)}px 'DM Mono', monospace`;
|
||||||
|
ctx.globalAlpha = 0.85; ctx.fillText(trunc(author, 18), w * 0.55, h * 0.86); ctx.globalAlpha = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
function wrapText(ctx, text, x, y, maxW, lineH) {
|
||||||
|
const words = text.split(' '); let line = '', lines = [];
|
||||||
|
for (const w of words) {
|
||||||
|
const t = line ? line + ' ' + w : w;
|
||||||
|
if (ctx.measureText(t).width > maxW && line) { lines.push(line); line = w; } else line = t;
|
||||||
|
}
|
||||||
|
if (line) lines.push(line); lines = lines.slice(0, 4);
|
||||||
|
const startY = y - ((lines.length - 1) * lineH) / 2;
|
||||||
|
lines.forEach((l, i) => ctx.fillText(l, x, startY + i * lineH));
|
||||||
|
}
|
||||||
|
|
||||||
|
function trunc(s, n) { return s.length > n ? s.slice(0, n - 1) + '…' : s; }
|
||||||
|
function esc(s) { return String(s || '').replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"'); }
|
||||||
function jsEsc(s) { return String(s || '').replace(/\\/g,'\\\\').replace(/'/g,"\\'"); }
|
function jsEsc(s) { return String(s || '').replace(/\\/g,'\\\\').replace(/'/g,"\\'"); }
|
||||||
function cssId(s) { return String(s || '').replace(/[^a-zA-Z0-9]/g, '_'); }
|
function cssId(s) { return String(s || '').replace(/[^a-zA-Z0-9]/g, '_'); }
|
||||||
|
|
||||||
@ -324,6 +365,15 @@
|
|||||||
if (row) row.outerHTML = starsHtml(filename, result.rating);
|
if (row) row.outerHTML = starsHtml(filename, result.rating);
|
||||||
} catch {}
|
} 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) {
|
function attachCover(coverEl, canvasEl, b) {
|
||||||
const title = bookTitle(b);
|
const title = bookTitle(b);
|
||||||
@ -334,11 +384,11 @@
|
|||||||
img.src = `/library/cover-cached/${encodeURIComponent(b.filename)}`;
|
img.src = `/library/cover-cached/${encodeURIComponent(b.filename)}`;
|
||||||
img.alt = title;
|
img.alt = title;
|
||||||
img.onload = () => { canvasEl.style.display = 'none'; };
|
img.onload = () => { canvasEl.style.display = 'none'; };
|
||||||
img.onerror = () => requestAnimationFrame(() => makePlaceholderCover(canvasEl, title, author));
|
img.onerror = () => requestAnimationFrame(() => makePlaceholder(canvasEl, title, author));
|
||||||
coverEl.style.position = 'relative';
|
coverEl.style.position = 'relative';
|
||||||
coverEl.insertBefore(img, coverEl.firstChild);
|
coverEl.insertBefore(img, coverEl.firstChild);
|
||||||
}
|
}
|
||||||
requestAnimationFrame(() => makePlaceholderCover(canvasEl, title, author));
|
requestAnimationFrame(() => makePlaceholder(canvasEl, title, author));
|
||||||
}
|
}
|
||||||
|
|
||||||
function makeHCard(b, showProgress) {
|
function makeHCard(b, showProgress) {
|
||||||
@ -417,16 +467,22 @@
|
|||||||
img.src = `/library/cover-cached/${encodeURIComponent(b.filename)}`;
|
img.src = `/library/cover-cached/${encodeURIComponent(b.filename)}`;
|
||||||
img.alt = bookTitle(b);
|
img.alt = bookTitle(b);
|
||||||
img.onload = () => { canvas.style.display = 'none'; };
|
img.onload = () => { canvas.style.display = 'none'; };
|
||||||
img.onerror = () => requestAnimationFrame(() => makePlaceholderCover(canvas, bookTitle(b), bookAuthor(b)));
|
img.onerror = () => requestAnimationFrame(() => makePlaceholder(canvas, bookTitle(b), bookAuthor(b)));
|
||||||
img.style.cssText = 'position:absolute;inset:0;width:100%;height:100%;object-fit:cover';
|
img.style.cssText = 'position:absolute;inset:0;width:100%;height:100%;object-fit:cover';
|
||||||
canvas.parentElement.insertBefore(img, canvas);
|
canvas.parentElement.insertBefore(img, canvas);
|
||||||
}
|
}
|
||||||
requestAnimationFrame(() => makePlaceholderCover(canvas, bookTitle(b), bookAuthor(b)));
|
requestAnimationFrame(() => makePlaceholder(canvas, bookTitle(b), bookAuthor(b)));
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function searchResults(query) {
|
function searchResults(query) {
|
||||||
return filterBooks(allBooks, query);
|
const q = String(query || '').trim().toLowerCase();
|
||||||
|
if (!q) return [];
|
||||||
|
return allBooks.filter(b =>
|
||||||
|
bookTitle(b).toLowerCase().includes(q) ||
|
||||||
|
bookAuthor(b).toLowerCase().includes(q) ||
|
||||||
|
(b.genres || []).some(g => String(g || '').toLowerCase().includes(q))
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function switchView(view) {
|
function switchView(view) {
|
||||||
@ -472,9 +528,16 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function setupSearch() {
|
function setupSearch() {
|
||||||
setupSearchInput('home-search-input', 'home-search-clear', q => {
|
const input = document.getElementById('home-search-input');
|
||||||
if (q) switchView('search');
|
const clear = document.getElementById('home-search-clear');
|
||||||
else if (currentView === 'search') switchView('home');
|
input.addEventListener('input', () => {
|
||||||
|
const q = input.value.trim();
|
||||||
|
clear.style.display = q ? '' : 'none';
|
||||||
|
clearTimeout(searchTimer);
|
||||||
|
searchTimer = setTimeout(() => {
|
||||||
|
if (q) switchView('search');
|
||||||
|
else if (currentView === 'search') switchView('home');
|
||||||
|
}, 180);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -4,15 +4,29 @@
|
|||||||
<meta charset="UTF-8"/>
|
<meta charset="UTF-8"/>
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
|
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
|
||||||
<title>Novela</title>
|
<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 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 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/sidebar.css"/>
|
||||||
<style>
|
<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; }
|
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||||
|
|
||||||
html, body { height: 100%; }
|
html, body { height: 100%; }
|
||||||
@ -296,8 +310,6 @@
|
|||||||
|
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<script src="/static/books.js"></script>
|
|
||||||
<script src="/static/conversion.js"></script>
|
|
||||||
<script>
|
<script>
|
||||||
let currentUrl = '';
|
let currentUrl = '';
|
||||||
let coverB64 = null;
|
let coverB64 = null;
|
||||||
@ -460,9 +472,91 @@
|
|||||||
document.getElementById('convert-label').textContent = 'Convert';
|
document.getElementById('convert-label').textContent = 'Convert';
|
||||||
document.getElementById('convert-spinner').style.display = 'none';
|
document.getElementById('convert-spinner').style.display = 'none';
|
||||||
|
|
||||||
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();
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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, '&').replace(/</g, '<').replace(/>/g, '>');
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@ -4,13 +4,8 @@
|
|||||||
<meta charset="UTF-8"/>
|
<meta charset="UTF-8"/>
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
|
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
|
||||||
<title>Novela — Library</title>
|
<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 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 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/sidebar.css"/>
|
||||||
<link rel="stylesheet" href="/static/library.css"/>
|
<link rel="stylesheet" href="/static/library.css"/>
|
||||||
</head>
|
</head>
|
||||||
@ -98,7 +93,6 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script src="/static/books.js"></script>
|
|
||||||
<script src="/static/library.js"></script>
|
<script src="/static/library.js"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@ -4,15 +4,15 @@
|
|||||||
<meta charset="UTF-8"/>
|
<meta charset="UTF-8"/>
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
|
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
|
||||||
<title>Novela — {{ title }}</title>
|
<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 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 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>
|
<style>
|
||||||
:root {
|
: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;
|
--header-h: 50px; --footer-h: 36px;
|
||||||
--content-w: 65vw;
|
--content-w: 65vw;
|
||||||
}
|
}
|
||||||
@ -67,8 +67,8 @@
|
|||||||
.btn-header:hover { color: var(--text); border-color: var(--text-faint); }
|
.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 { 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-read:hover { background: rgba(107,170,107,0.08); border-color: var(--success); }
|
||||||
.btn-header-bm { color: var(--accent); border-color: rgba(255,162,14,0.3); }
|
.btn-header-bm { color: var(--accent); border-color: rgba(200,120,58,0.3); }
|
||||||
.btn-header-bm:hover { background: rgba(255,162,14,0.08); border-color: var(--accent); }
|
.btn-header-bm:hover { background: rgba(200,120,58,0.08); border-color: var(--accent); }
|
||||||
|
|
||||||
/* ── Bookmark modal ── */
|
/* ── Bookmark modal ── */
|
||||||
.bm-overlay {
|
.bm-overlay {
|
||||||
@ -382,7 +382,6 @@
|
|||||||
<div class="footer-pct" id="footer-pct">0%</div>
|
<div class="footer-pct" id="footer-pct">0%</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script src="/static/books.js"></script>
|
|
||||||
<script>
|
<script>
|
||||||
const filename = {{ filename | tojson }};
|
const filename = {{ filename | tojson }};
|
||||||
const FORMAT = {{ format | tojson }};
|
const FORMAT = {{ format | tojson }};
|
||||||
@ -670,6 +669,9 @@
|
|||||||
if (e.target === e.currentTarget) closeBookmarkModal();
|
if (e.target === e.currentTarget) closeBookmarkModal();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
function esc(s) {
|
||||||
|
return String(s ?? '').replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
||||||
|
}
|
||||||
|
|
||||||
init();
|
init();
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@ -4,15 +4,18 @@
|
|||||||
<meta charset="UTF-8"/>
|
<meta charset="UTF-8"/>
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
|
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
|
||||||
<title>Novela — Settings</title>
|
<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 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 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/sidebar.css"/>
|
||||||
<style>
|
<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; }
|
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||||
html, body { height: 100%; background: var(--bg); color: var(--text); font-family: var(--serif); }
|
html, body { height: 100%; background: var(--bg); color: var(--text); font-family: var(--serif); }
|
||||||
|
|
||||||
@ -254,7 +257,6 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script src="/static/books.js"></script>
|
|
||||||
<script>
|
<script>
|
||||||
// ── Break patterns ─────────────────────────────────────────────────────────
|
// ── Break patterns ─────────────────────────────────────────────────────────
|
||||||
let bpPatterns = [];
|
let bpPatterns = [];
|
||||||
@ -381,6 +383,9 @@
|
|||||||
fb.textContent = '✗ Not recognized as a break. (CSS classes are not tested here — they apply to HTML attributes.)';
|
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, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
||||||
|
}
|
||||||
|
|
||||||
// Enter key in add inputs
|
// Enter key in add inputs
|
||||||
document.addEventListener('DOMContentLoaded', () => {
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
|
|||||||
@ -4,16 +4,19 @@
|
|||||||
<meta charset="UTF-8"/>
|
<meta charset="UTF-8"/>
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
|
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
|
||||||
<title>Novela — Statistics</title>
|
<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 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 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/sidebar.css"/>
|
||||||
<script src="https://cdn.jsdelivr.net/npm/chart.js@4/dist/chart.umd.min.js"></script>
|
<script src="https://cdn.jsdelivr.net/npm/chart.js@4/dist/chart.umd.min.js"></script>
|
||||||
<style>
|
<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; }
|
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||||
html, body { height: 100%; background: var(--bg); color: var(--text); font-family: var(--serif); }
|
html, body { height: 100%; background: var(--bg); color: var(--text); font-family: var(--serif); }
|
||||||
|
|
||||||
@ -161,16 +164,18 @@
|
|||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<script src="/static/books.js"></script>
|
|
||||||
<script>
|
<script>
|
||||||
Chart.defaults.color = '#8a8278';
|
Chart.defaults.color = '#8a8278';
|
||||||
Chart.defaults.borderColor = '#2e2a24';
|
Chart.defaults.borderColor = '#2e2a24';
|
||||||
Chart.defaults.font.family = "'DM Mono', monospace";
|
Chart.defaults.font.family = "'DM Mono', monospace";
|
||||||
Chart.defaults.font.size = 11;
|
Chart.defaults.font.size = 11;
|
||||||
|
|
||||||
const ACCENT = '#ffa20e';
|
const ACCENT = '#c8783a';
|
||||||
const ACCENT_A = 'rgba(255,162,14,0.15)';
|
const ACCENT_A = 'rgba(200,120,58,0.15)';
|
||||||
|
|
||||||
|
function esc(s) {
|
||||||
|
return String(s ?? '').replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
||||||
|
}
|
||||||
|
|
||||||
function fmtDate(iso) {
|
function fmtDate(iso) {
|
||||||
const d = new Date(iso);
|
const d = new Date(iso);
|
||||||
|
|||||||
@ -248,87 +248,6 @@ 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
|
## UI Notes
|
||||||
- Library import accepts EPUB/PDF/CBR/CBZ.
|
- Library import accepts EPUB/PDF/CBR/CBZ.
|
||||||
- Home supports the same import formats.
|
- Home supports the same import formats.
|
||||||
|
|||||||
@ -1,47 +1,12 @@
|
|||||||
# Develop Changelog
|
# Develop Changelog
|
||||||
|
|
||||||
## 2026-03-29 (10)
|
## 2026-03-29 (14)
|
||||||
- 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
|
- 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 (2)
|
## 2026-03-29 (13)
|
||||||
- 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
|
- 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 (1)
|
## 2026-03-29 (12)
|
||||||
- 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
|
- 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)
|
## 2026-03-28 (11)
|
||||||
|
|||||||
@ -1,99 +0,0 @@
|
|||||||
# 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
|
|
||||||
- 1–5 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 (30–100 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
|
|
||||||
@ -1 +1 @@
|
|||||||
v0.1.2
|
v0.1.1
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user