Files
masi 342dd549bb feat: wiki redesign with character editing; richer novel requests; maintenance as a Job
- wiki.html: tabs (characters ranked by chapters, illustrations lightbox,
  A-Z glossary, story in groups, style), search, character sheet with a look
  timeline and Edit mode: rename, role, aliases, look label/appearance/first
  chapter, redraw, upload a picture, delete look, merge, split, delete.
- /v1/wiki JSON; ?w= thumbnails; old positions backfilled from Kavita paths.
- /library groups results by novel with cover, author, status, genres,
  summary and per-source chapter counts (free vs locked, latest), marks
  novels already in the library; ?q= starts a search.
- maintain-job.sh runs cleanup as a Job; consolidation skips characters on
  GLM timeouts; GLM-only jobs run off the GPU queue; opening a chapter queued
  as a prefetch moves it up.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-26 16:10:26 -03:00

477 lines
29 KiB
HTML
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Novel wiki</title>
<style>
:root {
--bg: #f5f3ee; --panel: #ffffff; --panel-2: #efece5; --fg: #1d1b18; --muted: #6a655c;
--line: #e0dbd1; --accent: #8a5a2b; --accent-fg: #ffffff; --danger: #b3412e; --ok: #3f7d4f;
--shadow: 0 1px 2px rgba(0,0,0,.06), 0 4px 16px rgba(0,0,0,.06);
}
@media (prefers-color-scheme: dark) {
:root { --bg: #121110; --panel: #1c1a18; --panel-2: #24211e; --fg: #ece7de; --muted: #a39c90;
--line: #332f2a; --accent: #d9a066; --accent-fg: #1a1208; --danger: #e0715c; --ok: #7cc28f;
--shadow: 0 1px 2px rgba(0,0,0,.4), 0 6px 24px rgba(0,0,0,.35); }
}
* { box-sizing: border-box; }
html { -webkit-text-size-adjust: 100%; }
body { margin: 0; background: var(--bg); color: var(--fg);
font: 15px/1.5 system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; }
a { color: inherit; }
button, input, textarea, select { font: inherit; color: inherit; }
button { cursor: pointer; border: 1px solid var(--line); background: var(--panel); border-radius: 10px;
padding: 7px 12px; }
button.primary { background: var(--accent); color: var(--accent-fg); border-color: var(--accent); }
button.danger { color: var(--danger); border-color: var(--danger); background: transparent; }
button:disabled { opacity: .55; cursor: default; }
input, textarea, select { background: var(--panel); border: 1px solid var(--line); border-radius: 10px;
padding: 8px 10px; width: 100%; }
textarea { min-height: 84px; resize: vertical; }
.muted { color: var(--muted); }
.small { font-size: .85em; }
header { position: sticky; top: 0; z-index: 20; background: color-mix(in srgb, var(--bg) 92%, transparent);
backdrop-filter: blur(10px); border-bottom: 1px solid var(--line); }
.bar { max-width: 1180px; margin: 0 auto; padding: 12px 16px 0; }
.crumbs { font-size: .85em; }
h1 { font: 700 1.5em/1.2 Georgia, "Iowan Old Style", serif; margin: 4px 0 2px; }
.searchrow { display: flex; gap: 8px; margin: 10px 0; }
nav.tabs { display: flex; gap: 4px; overflow-x: auto; scrollbar-width: none; }
nav.tabs::-webkit-scrollbar { display: none; }
nav.tabs button { border: 0; background: none; border-radius: 0; padding: 10px 12px; color: var(--muted);
border-bottom: 2px solid transparent; white-space: nowrap; }
nav.tabs button[aria-selected=true] { color: var(--fg); border-bottom-color: var(--accent); font-weight: 600; }
nav.tabs .count { font-size: .8em; color: var(--muted); margin-left: 4px; }
main { max-width: 1180px; margin: 0 auto; padding: 16px 16px 80px; }
.toolbar { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; margin-bottom: 14px; }
.chip { display: inline-flex; align-items: center; gap: 4px; border: 1px solid var(--line); border-radius: 999px;
padding: 2px 10px; font-size: .82em; background: var(--panel); }
button.chip[aria-pressed=true] { background: var(--fg); color: var(--bg); border-color: var(--fg); }
.empty { padding: 40px 0; text-align: center; color: var(--muted); }
/* characters */
.people { display: grid; gap: 14px; grid-template-columns: repeat(auto-fill, minmax(170px, 1fr)); }
.person { background: var(--panel); border: 1px solid var(--line); border-radius: 14px; overflow: hidden;
box-shadow: var(--shadow); cursor: pointer; display: flex; flex-direction: column; text-align: left;
padding: 0; }
.person:focus-visible { outline: 2px solid var(--accent); }
.portrait { aspect-ratio: 2 / 3; width: 100%; object-fit: cover; object-position: top; display: block;
background: var(--panel-2); }
.monogram { aspect-ratio: 2 / 3; display: grid; place-items: center; background: var(--panel-2);
font: 700 2.4em Georgia, serif; color: var(--muted); }
.person .body { padding: 10px 12px 12px; display: grid; gap: 4px; }
.person .name { font-weight: 700; line-height: 1.25; }
.clamp { display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }
.chips { display: flex; flex-wrap: wrap; gap: 4px; min-width: 0; }
.person .chip { max-width: 100%; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; display: block; }
/* illustrations */
.gallery { display: grid; gap: 12px; grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); }
.shot { position: relative; border-radius: 12px; overflow: hidden; background: var(--panel-2); cursor: pointer;
border: 0; padding: 0; text-align: left; box-shadow: var(--shadow); }
.shot img { width: 100%; aspect-ratio: 16 / 11; object-fit: cover; display: block; }
.shot .cap { position: absolute; inset: auto 0 0 0; padding: 22px 10px 8px; color: #fff; font-size: .85em;
background: linear-gradient(transparent, rgba(0,0,0,.78)); }
/* glossary + story */
.letters { display: flex; flex-wrap: wrap; gap: 4px; margin-bottom: 12px; }
.letters a { text-decoration: none; min-width: 28px; text-align: center; padding: 3px 6px; border-radius: 8px;
border: 1px solid var(--line); background: var(--panel); font-size: .85em; }
.terms { columns: 2 340px; column-gap: 24px; }
.term { break-inside: avoid; padding: 10px 0; border-bottom: 1px solid var(--line); }
.term b { font-family: Georgia, serif; }
h3.letter { font: 700 1.2em Georgia, serif; margin: 18px 0 4px; color: var(--accent); break-after: avoid; }
details.group { background: var(--panel); border: 1px solid var(--line); border-radius: 12px; margin-bottom: 10px; }
details.group summary { cursor: pointer; padding: 12px 14px; font-weight: 600; }
.chapter { display: grid; grid-template-columns: 64px 1fr; gap: 10px; padding: 10px 14px;
border-top: 1px solid var(--line); }
.chapter .num { font-weight: 700; color: var(--accent); }
.chapter img { width: 100%; max-width: 320px; border-radius: 8px; margin-top: 6px; cursor: pointer; }
/* style */
.styles { display: grid; gap: 14px; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); }
.card { background: var(--panel); border: 1px solid var(--line); border-radius: 14px; overflow: hidden;
box-shadow: var(--shadow); }
.card img { width: 100%; display: block; }
.card .body { padding: 12px; display: grid; gap: 8px; }
/* sheet + lightbox */
.overlay { position: fixed; inset: 0; background: rgba(0,0,0,.55); z-index: 50; display: flex;
justify-content: center; align-items: flex-end; }
@media (min-width: 760px) { .overlay { align-items: center; } }
.sheet { background: var(--bg); width: min(880px, 100%); max-height: 92vh; overflow: auto;
border-radius: 18px 18px 0 0; box-shadow: var(--shadow); }
@media (min-width: 760px) { .sheet { border-radius: 18px; } }
.sheet .top { position: sticky; top: 0; background: var(--bg); display: flex; justify-content: space-between;
align-items: center; gap: 8px; padding: 12px 16px; border-bottom: 1px solid var(--line); z-index: 1; }
.sheet .content { padding: 16px; display: grid; gap: 16px; }
.hero { display: grid; grid-template-columns: 150px 1fr; gap: 16px; }
@media (max-width: 480px) { .hero { grid-template-columns: 110px 1fr; } }
.hero img, .hero .monogram { border-radius: 12px; width: 100%; }
.timeline { display: flex; gap: 12px; overflow-x: auto; padding-bottom: 6px; scrollbar-width: thin; }
.timeline .look { flex: 0 0 120px; font-size: .82em; }
.timeline .look img, .timeline .look .monogram { width: 120px; border-radius: 10px; }
.timeline .look .monogram { font-size: 1em; padding: 8px; text-align: center; }
fieldset { border: 1px solid var(--line); border-radius: 12px; padding: 12px; display: grid; gap: 10px; margin: 0; }
legend { padding: 0 6px; font-weight: 600; }
.row { display: flex; gap: 8px; flex-wrap: wrap; align-items: center; }
.row > * { flex: 1 1 160px; }
.row > button { flex: 0 0 auto; }
.lookedit { display: grid; grid-template-columns: 96px 1fr; gap: 12px; border-top: 1px solid var(--line);
padding-top: 12px; }
.lookedit img, .lookedit .monogram { width: 96px; border-radius: 10px; font-size: 1em; }
.status { font-size: .88em; color: var(--muted); min-height: 1.2em; }
.lightbox { position: fixed; inset: 0; z-index: 60; background: rgba(0,0,0,.92); display: grid;
grid-template-rows: 1fr auto; color: #eee; }
.lightbox img { max-width: 100%; max-height: 100%; margin: auto; object-fit: contain; align-self: center; }
.lightbox .foot { padding: 12px 16px; display: flex; gap: 12px; align-items: center; justify-content: space-between; }
.lightbox button { background: rgba(255,255,255,.1); color: #fff; border-color: rgba(255,255,255,.25); }
</style>
</head>
<body data-novel="__NOVEL__">
<header>
<div class="bar">
<div class="crumbs muted"><a href="/gallery">All novels</a> · <a href="/library">Request a novel</a></div>
<h1 id="title">Loading…</h1>
<div id="subtitle" class="muted small"></div>
<div class="searchrow"><input id="search" type="search" placeholder="Search characters, terms, story…" autocomplete="off"></div>
<nav class="tabs" id="tabs" role="tablist"></nav>
</div>
</header>
<main id="main"></main>
<script>
'use strict';
const NOVEL = document.body.dataset.novel;
const TABS = [['characters', 'Characters'], ['illustrations', 'Illustrations'], ['glossary', 'Glossary'],
['story', 'Story so far'], ['style', 'Art style']];
const MAIN_CHAPTERS = 20; // "Main" characters appear in at least this many chapters
let data = null, tab = 'characters', query = '', showAll = false;
// Tiny DOM builder: text always goes through textContent, never HTML.
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;
}
const thumb = (url, w) => url ? url + (url.includes('?') ? '&' : '?') + 'w=' + w : null;
const matches = (...parts) => !query || parts.some(p => p && String(p).toLowerCase().includes(query));
const chapterNo = p => 'Ch. ' + (p + 1);
function portraitOrMonogram(c, url, w, cls) {
return url ? h('img', { class: cls || 'portrait', src: thumb(url, w), alt: c.name, loading: 'lazy' })
: h('div', { class: 'monogram' }, (c.name || '?').trim().charAt(0));
}
async function api(path, body) {
const res = await fetch(path, body ? { method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body) } : {});
const json = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(json.error || ('HTTP ' + res.status));
return json;
}
const character = body => api('/v1/characters', { novel: NOVEL, ...body });
async function waitJob(id, status) {
for (;;) {
const j = await api('/v1/jobs/' + id);
if (j.status === 'done') return;
if (j.status === 'failed' || j.status === 'missing') throw new Error(j.error || 'the job failed');
status.textContent = j.status === 'running' ? 'Painting… about 30 seconds'
: 'Queued' + (j.queued_ahead ? ' (' + j.queued_ahead + ' ahead)' : '') + '…';
await new Promise(r => setTimeout(r, 4000));
}
}
async function load() {
data = await api('/v1/wiki?novel=' + encodeURIComponent(NOVEL));
document.title = data.title + ' · wiki';
document.getElementById('title').textContent = data.title;
document.getElementById('subtitle').textContent =
(data.limit === null ? 'Everything so far' : 'Up to chapter ' + (data.limit + 1) + ' — nothing later is shown') +
' · ' + data.characters.length + ' characters · ' + data.illustrations.length + ' illustrations';
render();
}
function renderTabs() {
const counts = { characters: data.characters.length, illustrations: data.illustrations.length,
glossary: data.terms.length, story: data.story.length };
const nav = document.getElementById('tabs');
nav.replaceChildren(...TABS.map(([id, label]) => h('button', {
role: 'tab', 'aria-selected': String(id === tab),
onclick: () => { tab = id; history.replaceState(null, '', '#' + id); render(); } },
label, counts[id] !== undefined ? h('span', { class: 'count', text: counts[id] }) : null)));
}
function render() {
renderTabs();
const main = document.getElementById('main');
main.replaceChildren(({ characters, illustrations, glossary, story, style })[tab]());
}
// ---- characters -------------------------------------------------------------------
// The portrait for a card: the look at the reader's chapter, else the latest drawn one.
const cardPortrait = c => c.portrait || [...c.timeline].reverse().map(l => l.portrait).find(Boolean) || null;
function characters() {
const mains = data.characters.filter(c => c.chapters >= MAIN_CHAPTERS);
const pool = (showAll || mains.length < 6 || query) ? data.characters : mains;
const list = pool.filter(c => matches(c.name, c.role, ...(c.aliases || [])));
const bar = h('div', { class: 'toolbar' },
h('button', { class: 'chip', 'aria-pressed': String(!showAll && mains.length >= 6),
onclick: () => { showAll = false; render(); } }, 'Main · ' + mains.length),
h('button', { class: 'chip', 'aria-pressed': String(showAll || mains.length < 6),
onclick: () => { showAll = true; render(); } }, 'All · ' + data.characters.length),
h('span', { class: 'muted small', text: 'Ranked by how many chapters they appear in. Tap for details and editing.' }));
if (!list.length) return h('div', {}, bar, h('p', { class: 'empty', text: 'No characters match.' }));
return h('div', {}, bar, h('div', { class: 'people' }, list.map(c => {
const aliases = c.aliases || [];
return h('button', { class: 'person', onclick: () => openCharacter(c.name) },
portraitOrMonogram(c, cardPortrait(c), 240),
h('div', { class: 'body' },
h('div', { class: 'name', text: c.name }),
h('div', { class: 'muted small', text: c.chapters + ' chapter' + (c.chapters === 1 ? '' : 's') +
(c.timeline.length > 1 ? ' · ' + c.timeline.length + ' looks' : '') }),
c.role ? h('div', { class: 'small clamp', text: c.role }) : null,
// The two shortest aliases on the card; the rest are in the detail sheet.
aliases.length ? h('div', { class: 'chips' }, [...aliases].sort((a, b) => a.length - b.length).slice(0, 2)
.map(a => h('span', { class: 'chip', title: a, text: a })),
aliases.length > 2 ? h('span', { class: 'chip muted', text: '+' + (aliases.length - 2) }) : null) : null));
})));
}
function closeOverlay() { document.querySelectorAll('.overlay, .lightbox').forEach(o => o.remove()); }
addEventListener('keydown', e => { if (e.key === 'Escape') closeOverlay(); });
function openCharacter(name, editing) {
closeOverlay();
const c = data.characters.find(x => x.name === name);
if (!c) return;
const content = h('div', { class: 'content' });
const sheet = h('div', { class: 'sheet', role: 'dialog', 'aria-label': c.name },
h('div', { class: 'top' }, h('strong', { text: c.name }),
h('div', { class: 'row', style: 'flex:0 0 auto' },
h('button', { onclick: () => openCharacter(name, !editing) }, editing ? 'Done' : 'Edit'),
h('button', { onclick: closeOverlay, 'aria-label': 'Close' }, '✕'))),
content);
const overlay = h('div', { class: 'overlay', onclick: e => { if (e.target === overlay) closeOverlay(); } }, sheet);
document.body.append(overlay);
content.append(...(editing ? editCharacter(c) : viewCharacter(c)));
}
function viewCharacter(c) {
const current = c.timeline.find(l => l.label === c.look) || c.timeline[c.timeline.length - 1] || {};
return [
h('div', { class: 'hero' }, portraitOrMonogram(c, cardPortrait(c), 480),
h('div', { style: 'display:grid;gap:8px;align-content:start' },
h('div', { class: 'muted small', text: c.chapters + ' chapters' + (c.look ? ' · current look: ' + c.look : '') }),
c.role ? h('div', { text: c.role }) : null,
(c.aliases || []).length ? h('div', { class: 'chips' }, c.aliases.map(a => h('span', { class: 'chip', text: a }))) : null,
h('div', { class: 'muted small', text: current.description || c.description || '' }))),
c.timeline.length ? h('div', {}, h('strong', { text: 'Looks over time' }),
h('div', { class: 'timeline' }, c.timeline.map(l => h('div', { class: 'look' },
l.portrait ? h('img', { src: thumb(l.portrait, 240), alt: l.label, loading: 'lazy' })
: h('div', { class: 'monogram', text: 'not drawn yet' }),
h('div', { text: l.label }), h('div', { class: 'muted', text: 'from ' + chapterNo(l.since) }))))) : null,
];
}
function editCharacter(c) {
const status = h('div', { class: 'status', role: 'status' });
const busy = async (fn, reopenAs) => {
status.textContent = 'Saving…';
try { const name = await fn(); await load(); openCharacter(reopenAs ? reopenAs(name) : c.name, true); }
catch (e) { status.textContent = '⚠️ ' + e.message; }
};
// Identity: name, role, aliases (chip editor).
const nameIn = h('input', { value: c.name, maxlength: 80 });
const roleIn = h('textarea', { maxlength: 400 }); roleIn.value = c.role || '';
let aliases = [...(c.aliases || [])];
const chipBox = h('div', { class: 'chips' });
const drawChips = () => chipBox.replaceChildren(...aliases.map((a, i) => h('span', { class: 'chip' }, a,
h('button', { style: 'border:0;padding:0 2px;background:none', 'aria-label': 'Remove ' + a,
onclick: () => { aliases.splice(i, 1); drawChips(); } }, '✕'))));
drawChips();
const aliasIn = h('input', { placeholder: 'Add an alias and press Enter (needs a capital, e.g. "Director Kim")' });
aliasIn.addEventListener('keydown', e => {
if (e.key !== 'Enter' || !aliasIn.value.trim()) return;
e.preventDefault(); aliases.push(aliasIn.value.trim()); aliasIn.value = ''; drawChips();
});
const identity = h('fieldset', {}, h('legend', { text: 'Character' }),
h('label', {}, 'Name', nameIn), h('label', {}, 'Who they are (role)', roleIn),
h('div', {}, h('div', { class: 'small muted', text: 'Aliases' }), chipBox), aliasIn,
h('div', { class: 'row' }, h('button', { class: 'primary', onclick: () => busy(async () =>
(await character({ action: 'edit', name: c.name, new_name: nameIn.value.trim(), role: roleIn.value.trim(),
aliases })).name, n => n) }, 'Save')));
// Looks: label, description, first chapter, redraw, upload, delete.
const looks = h('fieldset', {}, h('legend', { text: 'Looks' }), c.timeline.map(l => {
const labelIn = h('input', { value: l.label, maxlength: 60 });
const descIn = h('textarea', { maxlength: 600 }); descIn.value = l.description || '';
const sinceIn = h('input', { type: 'number', min: 1, value: l.since + 1, style: 'max-width:110px' });
const hintIn = h('input', { placeholder: 'Redraw with a correction, e.g. "older, with a beard" (optional)', maxlength: 300 });
const file = h('input', { type: 'file', accept: 'image/*', style: 'display:none' });
const lookStatus = h('div', { class: 'status' });
const run = async (body) => {
try { const j = await character({ name: c.name, label: l.label, ...body }); await waitJob(j.id, lookStatus);
await load(); openCharacter(c.name, true); }
catch (e) { lookStatus.textContent = '⚠️ ' + e.message; }
};
file.addEventListener('change', () => {
const f = file.files[0]; if (!f) return;
if (f.size > 6 * 1024 * 1024) { lookStatus.textContent = '⚠️ Please pick an image under 6 MB.'; return; }
const reader = new FileReader();
reader.onload = () => { lookStatus.textContent = 'Uploading…'; run({ action: 'upload', image: reader.result }); };
reader.readAsDataURL(f);
});
const others = c.timeline.filter(o => o.label !== l.label);
const into = h('select', {}, others.map(o => h('option', { value: o.label, text: o.label })));
return h('div', { class: 'lookedit' },
l.portrait ? h('img', { src: thumb(l.portrait, 240), alt: l.label }) : h('div', { class: 'monogram', text: '—' }),
h('div', { style: 'display:grid;gap:8px' },
h('div', { class: 'row' }, h('label', {}, 'Label', labelIn), h('label', { style: 'flex:0 0 120px' }, 'From chapter', sinceIn)),
h('label', {}, 'Appearance', descIn),
h('div', { class: 'row' }, h('button', { onclick: () => busy(async () => {
await character({ action: 'edit_look', name: c.name, label: l.label, new_label: labelIn.value.trim(),
description: descIn.value.trim(), since: Math.max(0, Number(sinceIn.value) - 1) });
}) }, 'Save look')),
h('div', { class: 'row' }, hintIn,
h('button', { onclick: () => run({ action: 'redraw', hint: hintIn.value.trim() }) }, l.portrait ? '🎨 Redraw' : '🎨 Draw'),
h('button', { onclick: () => file.click() }, '⬆ Upload picture'), file),
others.length ? h('div', { class: 'row' }, h('span', { class: 'small muted', style: 'flex:0 0 auto', text: 'Delete; its chapters use' }), into,
h('button', { class: 'danger', onclick: () => busy(async () => {
await character({ action: 'delete_look', name: c.name, label: l.label, into: into.value }); }) }, 'Delete look')) : null,
lookStatus));
}));
// Merge / split / delete.
const target = h('select', {}, data.characters.filter(o => o.name !== c.name)
.map(o => h('option', { value: o.name, text: o.name })));
const splitName = h('input', { placeholder: 'Name of the other character', maxlength: 80 });
const splitLooks = c.timeline.map(l => h('label', { class: 'chip' }, h('input', { type: 'checkbox', value: l.label, style: 'width:auto' }), l.label));
const splitAliases = (c.aliases || []).map(a => h('label', { class: 'chip' }, h('input', { type: 'checkbox', value: a, style: 'width:auto' }), a));
let armed = false;
const del = h('button', { class: 'danger', onclick: () => {
if (!armed) { armed = true; del.textContent = 'Tap again to delete ' + c.name; return; }
status.textContent = 'Deleting…';
character({ action: 'delete', name: c.name })
.then(load).then(closeOverlay)
.catch(e => { status.textContent = '⚠️ ' + e.message; });
} }, 'Delete character');
const people = h('fieldset', {}, h('legend', { text: 'Fix mix-ups' }),
h('div', { class: 'row' }, h('span', { class: 'small', style: 'flex:0 0 auto', text: 'Same person as' }), target,
h('button', { onclick: () => busy(async () => { await character({ action: 'merge', name: c.name, into: target.value }); return target.value; }, n => n) }, 'Merge')),
c.timeline.length > 1 ? h('div', { style: 'display:grid;gap:8px' },
h('div', { class: 'small', text: 'Two people mixed into one? Move the looks and aliases that belong to the other person:' }),
h('div', { class: 'chips' }, splitLooks), splitAliases.length ? h('div', { class: 'chips' }, splitAliases) : null,
h('div', { class: 'row' }, splitName, h('button', { onclick: () => busy(async () => {
const pick = els => els.map(e => e.querySelector('input')).filter(i => i.checked).map(i => i.value);
await character({ action: 'split', name: c.name, new_name: splitName.value.trim(),
looks: pick(splitLooks), aliases: pick(splitAliases) });
}) }, 'Split'))) : null,
h('div', { class: 'row' }, del));
return [status, identity, looks, people];
}
// ---- illustrations --------------------------------------------------------------------
function illustrations() {
const list = data.illustrations.filter(i => matches(i.moment, i.chapter_title));
if (!list.length) return h('p', { class: 'empty', text: 'No illustrations yet.' });
return h('div', { class: 'gallery' }, list.map((i, n) => h('button', { class: 'shot', onclick: () => lightbox(list, n) },
h('img', { src: thumb(i.image, 480), alt: i.moment, loading: 'lazy' }),
h('div', { class: 'cap' }, h('strong', { text: i.chapter_title || chapterNo(i.position || 0) })))));
}
function lightbox(list, index) {
closeOverlay();
const i = list[index];
const go = d => lightbox(list, (index + d + list.length) % list.length);
const box = h('div', { class: 'lightbox', role: 'dialog' },
h('img', { src: i.image, alt: i.moment, onclick: closeOverlay }),
h('div', { class: 'foot' }, h('button', { onclick: () => go(-1), 'aria-label': 'Previous' }, '‹'),
h('div', { style: 'flex:1' }, h('strong', { text: i.chapter_title || chapterNo(i.position || 0) }),
h('div', { class: 'small', text: i.moment })),
h('button', { onclick: () => go(1), 'aria-label': 'Next' }, '›'),
h('button', { onclick: closeOverlay, 'aria-label': 'Close' }, '✕')));
box.tabIndex = -1;
box.addEventListener('keydown', e => { if (e.key === 'ArrowLeft') go(-1); if (e.key === 'ArrowRight') go(1); });
document.body.append(box); box.focus();
}
// ---- glossary ---------------------------------------------------------------------------
function glossary() {
const list = data.terms.filter(t => matches(t.term, t.meaning)).sort((a, b) => a.term.localeCompare(b.term));
if (!list.length) return h('p', { class: 'empty', text: 'No terms match.' });
const groups = {};
for (const t of list) (groups[t.term.charAt(0).toUpperCase()] ||= []).push(t);
const letters = Object.keys(groups);
return h('div', {},
h('div', { class: 'letters' }, letters.map(l => h('a', { href: '#L-' + l, onclick: e => {
e.preventDefault(); document.getElementById('L-' + l).scrollIntoView({ behavior: 'smooth' }); } }, l))),
h('div', { class: 'terms' }, letters.map(l => [h('h3', { class: 'letter', id: 'L-' + l, text: l }),
groups[l].map(t => h('div', { class: 'term' }, h('b', { text: t.term }),
h('span', { class: 'muted small', text: t.chapters ? ' · ' + t.chapters + ' chapters' : '' }),
h('div', { text: t.meaning })))])));
}
// ---- story -----------------------------------------------------------------------------
function story() {
const pics = {};
data.illustrations.forEach((i, n) => { if (i.position !== null && !(i.position in pics)) pics[i.position] = n; });
const list = data.story.filter(s => matches(s.summary));
if (!list.length) return h('p', { class: 'empty', text: 'No chapter summaries yet.' });
const groups = [];
for (const s of list) {
const g = Math.floor(s.position / 10);
if (!groups.length || groups[groups.length - 1].g !== g) groups.push({ g, items: [] });
groups[groups.length - 1].items.push(s);
}
return h('div', {}, groups.map((grp, gi) => h('details', { class: 'group', open: query || gi === groups.length - 1 },
h('summary', { text: 'Chapters ' + (grp.g * 10 + 1) + '–' + (grp.g * 10 + 10) }),
grp.items.map(s => h('div', { class: 'chapter' }, h('div', { class: 'num', text: chapterNo(s.position) }),
h('div', {}, s.summary, s.position in pics ? h('img', {
src: thumb(data.illustrations[pics[s.position]].image, 480), alt: '', loading: 'lazy',
onclick: () => lightbox(data.illustrations, pics[s.position]) }) : null))))));
}
// ---- style -----------------------------------------------------------------------------
function style() {
const s = data.style, status = h('div', { class: 'status' });
const propose = async () => {
try { const j = await api('/v1/styles', { novel: NOVEL, action: 'propose' }); await waitJob(j.id, status); await load(); }
catch (e) { status.textContent = '⚠️ ' + e.message; }
};
return h('div', { style: 'display:grid;gap:16px' },
h('div', { class: 'card' }, h('div', { class: 'body' }, h('strong', { text: 'Current style' }),
h('div', { text: s.current || 'Not chosen yet: the first illustration picks one.' }),
h('div', { class: 'row' }, h('button', { class: 'primary', onclick: propose }, 'Try three other styles'), status))),
s.options.length ? h('div', { class: 'styles' }, s.options.map((o, i) => h('div', { class: 'card' },
h('img', { src: thumb(o.image, 720), alt: o.name + ' sample', loading: 'lazy' }),
h('div', { class: 'body' }, h('strong', { text: o.name }), h('div', { class: 'small muted', text: o.style }),
h('button', { onclick: async e => { e.target.disabled = true;
await api('/v1/styles', { novel: NOVEL, action: 'choose', index: i }); await load(); } }, 'Use this style'))))) : null,
s.options.length ? h('p', { class: 'muted small', text: 'Choosing a style redraws character portraits in it as they are needed; existing illustrations keep theirs until repainted (🔄 New image in the reader).' }) : null);
}
document.getElementById('search').addEventListener('input', e => { query = e.target.value.trim().toLowerCase(); render(); });
tab = (location.hash.slice(1) in { characters: 1, illustrations: 1, glossary: 1, story: 1, style: 1 }) ? location.hash.slice(1) : 'characters';
load().catch(e => { document.getElementById('main').replaceChildren(h('p', { class: 'empty', text: 'Could not load: ' + e.message })); });
</script>
</body>
</html>