342dd549bb
- 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>
477 lines
29 KiB
HTML
477 lines
29 KiB
HTML
<!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>
|