e9a259c074
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
287 lines
16 KiB
Python
287 lines
16 KiB
Python
"""A read-only gallery page per novel: illustrations in reading order and each character's
|
||
looks over time. Shows nothing past the furthest chapter read, so it never spoils."""
|
||
from html import escape
|
||
from urllib.parse import quote
|
||
|
||
import store
|
||
from config import HERE
|
||
|
||
STYLE = """
|
||
:root { --bg: #f6f4ef; --fg: #1f1d1a; --muted: #6b665d; --card: #ffffff; --line: #e3ded4; }
|
||
@media (prefers-color-scheme: dark) {
|
||
:root { --bg: #151412; --fg: #ece8df; --muted: #9c968a; --card: #1f1d1a; --line: #2e2b27; }
|
||
}
|
||
* { box-sizing: border-box; }
|
||
body { margin: 0; background: var(--bg); color: var(--fg); font: 16px/1.5 Georgia, 'Iowan Old Style', serif; }
|
||
main { max-width: 1100px; margin: 0 auto; padding: 24px 16px 64px; }
|
||
a { color: inherit; }
|
||
h1 { font-size: 1.8em; margin: 0 0 4px; }
|
||
h2 { font-size: 1.25em; margin: 40px 0 12px; padding-bottom: 6px; border-bottom: 1px solid var(--line); }
|
||
.muted { color: var(--muted); font-size: 0.9em; }
|
||
.grid { display: grid; gap: 16px; grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); }
|
||
figure { margin: 0; background: var(--card); border: 1px solid var(--line); border-radius: 10px; overflow: hidden; }
|
||
figure img { width: 100%; height: auto; display: block; }
|
||
figcaption { padding: 8px 12px 12px; font-size: 0.9em; }
|
||
.cast { display: grid; gap: 16px; grid-template-columns: repeat(auto-fill, minmax(320px, 1fr)); }
|
||
.person { background: var(--card); border: 1px solid var(--line); border-radius: 10px; padding: 12px; }
|
||
.looks { display: flex; gap: 10px; overflow-x: auto; margin-top: 8px; }
|
||
.look { flex: 0 0 110px; font-size: 0.8em; text-align: center; }
|
||
.look img { width: 110px; height: 160px; object-fit: cover; border-radius: 8px; display: block; }
|
||
.empty { width: 110px; height: 160px; border-radius: 8px; border: 1px dashed var(--line); display: grid;
|
||
place-items: center; color: var(--muted); }
|
||
ul.novels { list-style: none; padding: 0; } ul.novels li { padding: 10px 0; border-bottom: 1px solid var(--line); }
|
||
"""
|
||
|
||
|
||
def page(title, body):
|
||
return (f"<!doctype html><html lang=en><head><meta charset=utf-8>"
|
||
f"<meta name=viewport content='width=device-width,initial-scale=1'><title>{escape(title)}</title>"
|
||
f"<style>{STYLE}</style></head><body><main>{body}</main></body></html>").encode()
|
||
|
||
|
||
def index():
|
||
novels = store.gallery_novels()
|
||
cards = []
|
||
for n in novels:
|
||
latest = store.illustrations_of(n["novel"], store.FOREVER)
|
||
cover = (f"<img loading=lazy src='/v1/illustrations/{latest[-1]['id']}.webp?w=480' alt=''>"
|
||
if latest else "<div class=empty></div>")
|
||
cards.append(f"<a class=novelcard href='/gallery?novel={quote(n['novel'])}'>{cover}"
|
||
f"<div><strong>{escape(n['title'])}</strong><br><span class=muted>{n['count']} illustrations"
|
||
f"</span></div></a>")
|
||
style = ("<style>.novels{display:grid;gap:16px;grid-template-columns:repeat(auto-fill,minmax(260px,1fr));margin-top:16px}"
|
||
".novelcard{display:block;text-decoration:none;background:var(--card);border:1px solid var(--line);"
|
||
"border-radius:12px;overflow:hidden}.novelcard img,.novelcard .empty{width:100%;aspect-ratio:16/10;"
|
||
"object-fit:cover;display:block;background:var(--line)}.novelcard div{padding:10px 12px}</style>")
|
||
return page("Novel gallery", f"{style}<h1>Novel gallery</h1><p><a href='/library'>+ Request a novel</a></p>"
|
||
f"<div class=novels>{''.join(cards) or '<p>Nothing yet.</p>'}</div>")
|
||
|
||
|
||
def wiki_data(novel):
|
||
"""Everything the wiki page renders, cut at the furthest chapter read. Characters are
|
||
ranked by how many chapters they appear in; each carries its looks as a timeline."""
|
||
import pipeline
|
||
limit = store.max_position(novel)
|
||
cut = store.FOREVER if limit is None else limit
|
||
cards = store.codex(novel, limit)
|
||
with store.connect() as conn:
|
||
counts = store.mention_counts(conn, novel, cut)
|
||
characters = []
|
||
for c in cards["characters"]:
|
||
looks = []
|
||
for look in store.looks_of(conn, novel, c["name"]):
|
||
since = store.first_seen(conn, novel, c["name"], look["label"])
|
||
if since is None or since > cut:
|
||
continue
|
||
looks.append({"label": look["label"], "since": since, "description": look["description"],
|
||
"portrait": store.portrait_url(look["portrait"])})
|
||
looks.sort(key=lambda l: l["since"])
|
||
characters.append({**c, "chapters": counts.get(("c", c["name"]), 0), "timeline": looks})
|
||
characters.sort(key=lambda c: (-c["chapters"], c["name"]))
|
||
story = [dict(r) for r in conn.execute(
|
||
"SELECT position, summary, score FROM summaries WHERE novel = ? AND position <= ? AND summary != '' "
|
||
"ORDER BY position", (novel, cut))]
|
||
terms = [{**t, "chapters": counts.get(("t", t["term"]), 0)} for t in cards["terms"]]
|
||
illustrations = [{"id": i["id"], "position": i["position"], "chapter_title": i["chapter_title"],
|
||
"moment": i["moment"], "image": f"/v1/illustrations/{i['id']}.webp"}
|
||
for i in store.illustrations_of(novel, cut)]
|
||
return {"novel": novel, "title": store.novel_title(novel), "limit": limit, "characters": characters,
|
||
"terms": terms, "story": story, "illustrations": illustrations, "style": pipeline.styles(novel)}
|
||
|
||
|
||
WIKI_HTML = HERE / "wiki.html"
|
||
|
||
|
||
def wiki_page(novel):
|
||
return WIKI_HTML.read_text().replace("__NOVEL__", escape(novel, quote=True)).encode()
|
||
|
||
|
||
LIBRARY_SCRIPT = """
|
||
'use strict';
|
||
const $ = s => document.querySelector(s);
|
||
function h(tag, props, ...kids) {
|
||
const el = document.createElement(tag);
|
||
for (const [k, v] of Object.entries(props || {})) {
|
||
if (v === null || v === undefined || v === false) continue;
|
||
if (k.startsWith('on')) el.addEventListener(k.slice(2), v);
|
||
else if (k === 'class') el.className = v;
|
||
else if (k === 'text') el.textContent = v;
|
||
else el.setAttribute(k, v === true ? '' : v);
|
||
}
|
||
for (const kid of kids.flat(Infinity)) if (kid !== null && kid !== undefined && kid !== false)
|
||
el.append(kid instanceof Node ? kid : document.createTextNode(String(kid)));
|
||
return el;
|
||
}
|
||
async function api(path, body) {
|
||
const r = await fetch(path, body ? {method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify(body)} : {});
|
||
const data = await r.json().catch(() => ({}));
|
||
if (!r.ok) throw new Error(data.error || r.status);
|
||
return data;
|
||
}
|
||
const norm = t => (t || '').toLowerCase().replace(/[’']/g, '').replace(/[^a-z0-9]+/g, ' ').trim();
|
||
let library = new Set();
|
||
|
||
async function search(ev) {
|
||
ev.preventDefault();
|
||
const q = $('#q').value.trim();
|
||
if (q.length < 2) return;
|
||
$('#results').replaceChildren(h('p', {class: 'muted', text: 'Searching every source… about 30 seconds.'}));
|
||
try {
|
||
const [hits, owned] = await Promise.all([api('/v1/library/find?q=' + encodeURIComponent(q)),
|
||
api('/v1/library/titles').catch(() => [])]);
|
||
library = new Set(owned.map(norm));
|
||
render(hits);
|
||
} catch (e) { $('#results').replaceChildren(h('p', {text: 'Search failed: ' + e.message})); }
|
||
}
|
||
|
||
// One card per novel (same title across sources), sources listed inside with their counts.
|
||
function render(hits) {
|
||
if (!hits.length) return $('#results').replaceChildren(h('p', {text: 'No source has it.'}));
|
||
const groups = new Map();
|
||
for (const hit of hits) {
|
||
const k = norm(hit.name);
|
||
if (!groups.has(k)) groups.set(k, {name: hit.name, exact: hit.exact, sources: []});
|
||
groups.get(k).sources.push(hit);
|
||
}
|
||
const list = [...groups.values()].sort((a, b) => (b.exact - a.exact) || (b.sources.length - a.sources.length));
|
||
$('#results').replaceChildren(...list.map(card));
|
||
}
|
||
|
||
function card(group) {
|
||
const cover = h('div', {class: 'cover'}), meta = h('div', {class: 'meta muted small'});
|
||
const summary = h('p', {class: 'summary'});
|
||
const owned = library.has(norm(group.name));
|
||
const rows = group.sources.map(src => {
|
||
const counts = h('span', {class: 'muted small', text: 'loading details…'});
|
||
const add = h('button', {onclick: async () => {
|
||
add.disabled = true; add.textContent = 'Requested ↓';
|
||
try {
|
||
await api('/v1/library/fetch', {id: src.id, path: src.path, name: src.name});
|
||
await jobs();
|
||
$('#requests').scrollIntoView({behavior: 'smooth'});
|
||
}
|
||
catch (e) { add.disabled = false; add.textContent = 'Retry'; alert(e.message); }
|
||
}}, 'Add');
|
||
const row = h('div', {class: 'source'},
|
||
h('div', {}, h('strong', {text: src.source}), ' ', h('a', {href: src.url, target: '_blank', rel: 'noopener', class: 'small'}, 'open'),
|
||
h('div', {}, counts)), add);
|
||
row.load = () => api('/v1/library/info?id=' + encodeURIComponent(src.id) + '&path=' + encodeURIComponent(src.path))
|
||
.then(info => {
|
||
row.info = info;
|
||
const locked = info.chapters - info.free;
|
||
counts.textContent = info.chapters + ' chapters · ' +
|
||
(locked ? info.free + ' free, ' + locked + ' locked' : 'all free') +
|
||
(info.latest ? ' · latest: ' + info.latest.replace(' 🔒', '') : '');
|
||
if (!info.free) { add.disabled = true; add.textContent = 'All locked'; }
|
||
if (!cover.firstChild && info.cover) cover.append(h('img', {src: info.cover, alt: '', referrerpolicy: 'no-referrer', loading: 'lazy'}));
|
||
if (!meta.textContent) meta.textContent = [info.author, info.status, info.genres].filter(Boolean).join(' · ');
|
||
if (!summary.textContent && info.summary) summary.textContent = info.summary;
|
||
mark();
|
||
})
|
||
.catch(e => { counts.textContent = 'details unavailable (' + e.message + ')'; });
|
||
return row;
|
||
});
|
||
// Point at the source listing the most free chapters (split parts can inflate counts).
|
||
function mark() {
|
||
const best = rows.filter(r => r.info).sort((a, b) => b.info.free - a.info.free)[0];
|
||
rows.forEach(r => r.classList.toggle('best', r === best && best.info.free > 0));
|
||
}
|
||
const el = h('article', {class: 'novel'}, cover,
|
||
h('div', {class: 'info'},
|
||
h('h3', {}, group.name, group.exact ? h('span', {class: 'tag', text: 'exact'}) : null,
|
||
owned ? h('span', {class: 'tag done', text: 'in your library'}) : null),
|
||
meta, summary,
|
||
h('details', {open: group.exact}, h('summary', {text: group.sources.length + ' source' + (group.sources.length > 1 ? 's' : '')}), rows)));
|
||
// Details load when the card is opened (exact matches are open from the start).
|
||
const details = el.querySelector('details');
|
||
const loadAll = () => { rows.forEach(r => r.load()); details.removeEventListener('toggle', loadAll); };
|
||
if (group.exact) loadAll(); else details.addEventListener('toggle', loadAll);
|
||
return el;
|
||
}
|
||
|
||
const LABEL = {queued: 'Queued', running: 'Downloading', done: 'Done', failed: 'Failed'};
|
||
const ago = ms => { const m = Math.round((Date.now() - ms) / 60000); return m < 1 ? 'just now' : m < 60 ? m + ' min ago' : Math.round(m / 60) + ' h ago'; };
|
||
const mins = s => s < 60 ? Math.max(1, Math.round(s)) + ' s' : Math.round(s / 60) + ' min';
|
||
let jobTimer = null;
|
||
|
||
// The server keeps the requests, so this list is the same after a refresh.
|
||
async function jobs() {
|
||
clearTimeout(jobTimer);
|
||
try {
|
||
const list = await api('/v1/library/jobs');
|
||
const active = list.filter(j => j.status === 'queued' || j.status === 'running');
|
||
$('#active-count').textContent = active.length ? ' · ' + active.length + ' in progress' : '';
|
||
$('#jobs').replaceChildren(...(list.length ? list.map(j => {
|
||
const pct = j.total ? Math.round(100 * (j.done || 0) / j.total) : 0;
|
||
const elapsed = j.started ? (Date.now() - j.started) / 1000 : 0;
|
||
const eta = j.status === 'running' && j.done > 5 && j.total ? (elapsed / j.done) * (j.total - j.done) : null;
|
||
const detail = j.status === 'running'
|
||
? (j.total ? (j.done || 0) + ' / ' + j.total + ' chapters · ' + pct + '%' : (j.phase || 'starting')) +
|
||
(j.phase && j.total ? ' · ' + j.phase : '') + (eta ? ' · about ' + mins(eta) + ' left' : '')
|
||
: j.status === 'queued' ? (j.phase || 'waiting') + ' · requested ' + ago(j.queued)
|
||
: j.status === 'done' ? (j.progress || '') + ' · finished ' + ago(j.finished)
|
||
: 'failed ' + ago(j.finished || j.queued);
|
||
return h('li', {class: 'job ' + j.status},
|
||
h('div', {class: 'jobhead'}, h('strong', {text: j.title || j.name}),
|
||
h('span', {class: 'tag ' + j.status, text: LABEL[j.status] || j.status})),
|
||
h('div', {class: 'muted small', text: j.id + ' · ' + detail}),
|
||
j.status === 'running' ? h('div', {class: 'bar'}, h('div', {style: 'width:' + (j.total ? pct : 3) + '%'})) : null,
|
||
j.status === 'done' ? h('div', {class: 'small'}, 'Downloaded. Kavita lists it within about 10 minutes (it waits for files to settle); then open it in LNReader from the Kavita source, or in ',
|
||
h('a', {href: 'https://novel.marcospaulo.dev.br', target: '_blank', rel: 'noopener'}, 'Kavita'), '.') : null,
|
||
j.error ? h('div', {class: 'err small', text: j.error}) : null);
|
||
}) : [h('li', {class: 'muted', text: 'Nothing requested yet.'})]));
|
||
// Search results reflect finished downloads too.
|
||
list.filter(j => j.status === 'done').forEach(j => library.add(norm(j.title || j.name)));
|
||
if (active.length) jobTimer = setTimeout(jobs, 2000);
|
||
} catch (e) { $('#jobs').replaceChildren(h('li', {class: 'err', text: 'The fetcher is not reachable.'})); }
|
||
}
|
||
$('#search').onsubmit = search;
|
||
jobs();
|
||
// /library?q=Title searches straight away (shareable links, bookmarks).
|
||
const initial = new URLSearchParams(location.search).get('q');
|
||
if (initial) { $('#q').value = initial; search(new Event('submit')); }
|
||
"""
|
||
|
||
LIBRARY_STYLE = """
|
||
form { display: flex; gap: 8px; margin: 16px 0; }
|
||
input { flex: 1; font: inherit; padding: 10px 12px; border-radius: 10px; border: 1px solid var(--line);
|
||
background: var(--card); color: var(--fg); min-width: 0; }
|
||
button { font: inherit; padding: 8px 14px; border-radius: 10px; border: 1px solid var(--fg);
|
||
background: transparent; color: var(--fg); cursor: pointer; }
|
||
button:disabled { opacity: 0.6; cursor: default; }
|
||
.small { font-size: .85em; }
|
||
.novel { display: grid; grid-template-columns: 110px 1fr; gap: 14px; padding: 14px; margin-bottom: 12px;
|
||
background: var(--card); border: 1px solid var(--line); border-radius: 12px; }
|
||
@media (max-width: 520px) { .novel { grid-template-columns: 76px 1fr; } }
|
||
.cover img { width: 100%; border-radius: 8px; display: block; }
|
||
.cover:empty { aspect-ratio: 2 / 3; border-radius: 8px; background: var(--line); }
|
||
.novel h3 { margin: 0 0 4px; font-size: 1.1em; display: flex; flex-wrap: wrap; gap: 6px; align-items: center; }
|
||
.summary { margin: 6px 0; font-size: .9em; display: -webkit-box; -webkit-line-clamp: 3; -webkit-box-orient: vertical;
|
||
overflow: hidden; }
|
||
details summary { cursor: pointer; margin: 6px 0; }
|
||
.source { display: flex; gap: 10px; justify-content: space-between; align-items: center; padding: 8px 10px;
|
||
border-top: 1px solid var(--line); border-radius: 8px; }
|
||
.source.best { background: color-mix(in srgb, #3c8d5a 12%, transparent); }
|
||
.source.best strong::after { content: ' · most chapters listed'; font-weight: normal; font-size: .8em; color: #3c8d5a; }
|
||
.tag { font-size: 0.72em; padding: 1px 8px; border-radius: 999px; border: 1px solid var(--line); font-weight: normal; }
|
||
.done { border-color: #3c8d5a; color: #3c8d5a; }
|
||
.running { border-color: #b8862b; color: #b8862b; }
|
||
.job { display: grid; gap: 4px; }
|
||
.jobhead { display: flex; gap: 8px; align-items: center; justify-content: space-between; }
|
||
.bar { height: 6px; border-radius: 999px; background: var(--line); overflow: hidden; }
|
||
.bar div { height: 100%; background: #b8862b; transition: width .6s ease; } .failed, .err { color: #c0503a; border-color: #c0503a; }
|
||
ul#jobs { list-style: none; padding: 0; } ul#jobs li { padding: 8px 0; border-bottom: 1px solid var(--line); }
|
||
"""
|
||
|
||
|
||
def library_page():
|
||
"""Request a novel: search every source, add one to the Kavita library."""
|
||
return page("Request a novel",
|
||
f"<style>{LIBRARY_STYLE}</style><p class=muted><a href='/gallery'>Gallery</a></p>"
|
||
"<h1>Request a novel</h1><div class=muted>Searches every source in your plugin repo. "
|
||
"Added novels appear in Kavita (Novels) within about 10 minutes and get new chapters nightly. "
|
||
"Locked chapters are skipped.</div>"
|
||
"<form id=search><input id=q placeholder='Title, e.g. Overgeared' autocomplete=off>"
|
||
"<button>Search</button></form><div id=results></div>"
|
||
f"<h2 id=requests>Requests<span id=active-count class=muted></span></h2><ul id=jobs></ul><script>{LIBRARY_SCRIPT}</script>")
|