Add deployment reading windows and progress-aware quiz unlock

This commit is contained in:
Codex committed 2026-10-14 15:34:55 +02:00
1 parent edbc9c6056
commit d8eba9169f
15 files changed
+471 -5

No files matched your search

+8
View File
@@ -389,6 +389,14 @@ body.reader { position: fixed; top: 0; left: 0; right: 0; bottom: 0; overflow: h
#stage.pan { cursor: grab; }
#stage.drag { cursor: grabbing; }
#canvas { position: relative; }
.reading-gate { box-sizing: border-box; max-width: 620px; margin: min(13vh, 100px) auto 100px; padding: 24px;
border: 1px solid var(--line); border-radius: 14px; background: var(--card); color: var(--fg); line-height: 1.5; }
.reading-gate h1 { margin-top: 0; }
.reading-gate-question { margin: 20px 0; }
.reading-gate-question input { display: block; box-sizing: border-box; width: 100%; margin: 14px 0; padding: 10px; }
.reading-gate-actions { display: flex; flex-wrap: wrap; gap: 10px; }
.reading-gate-message { color: var(--accent); min-height: 1.5em; }
@media (max-width: 660px) { .reading-gate { margin: 28px 12px 100px; padding: 18px; } }
.pg { position: absolute; overflow: hidden; background: #19191c; }
.pg img { display: block; width: 100%; height: 100%; -webkit-user-drag: none; }
#canvas[data-page-tone="inverted"] .pg img { filter: invert(1); }
+57 -1
View File
@@ -12,9 +12,65 @@
const P = new URLSearchParams(location.search);
const stage = $('#stage'), canvas = $('#canvas'), slider = $('#slider'), ver = $('#ver');
let me, D;
try { [me, D] = await Promise.all([api('api/me'), api('api/chapter?' + qsa({ m: P.get('m'), c: P.get('c'), v: P.get('v') }))]); }
try {
me = await api('api/me');
const response = await fetch('api/chapter?' + qsa({ m: P.get('m'), c: P.get('c'), v: P.get('v') }));
if (response.status === 423) {
const access = await api('api/reading-access?' + qsa({ m: P.get('m') }));
canvas.textContent = '';
const card = h('section', { class: 'reading-gate' },
h('h1', {}, 'Reading is closed right now'),
h('p', {}, `The reading hours are set by this server's deployment (${access.timezone}). The library and your progress remain available.`),
h('p', {}, `Answer five questions drawn from manga you have already read to unlock ${access.unlockMinutes} minutes of reading. There is no answer timer or retry limit.`));
const message = h('p', { class: 'reading-gate-message', role: 'status' });
const question = h('div', { class: 'reading-gate-question' });
const start = h('button', { class: 'btn', type: 'button' }, 'Start five-question challenge');
const back = h('a', { class: 'btn', href: mangaUrl(P.get('m')) }, 'Back to manga');
card.append(message, question, h('div', { class: 'reading-gate-actions' }, start, back));
canvas.append(card);
let challenge = null;
const showQuestion = data => {
challenge = data.challenge;
question.textContent = '';
const input = h('input', { type: 'text', autocomplete: 'off', 'aria-label': 'Your answer', maxlength: '120', required: true });
const form = h('form', {}, h('p', {}, `Question ${data.number} of ${data.total}`),
h('p', { class: 'muted' }, data.mangaTitle), h('h2', {}, data.prompt), input, h('button', { class: 'btn', type: 'submit' }, 'Check answer'));
form.addEventListener('submit', async event => {
event.preventDefault();
try {
const result = await api('api/reading-quiz/answer', { m: P.get('m'), challenge, answer: input.value });
if (!result.correct) { message.textContent = 'Not quite. Try another spelling or answer—there is no attempt limit.'; input.select(); return; }
if (result.complete) { message.textContent = 'Unlocked. Opening your chapter…'; location.reload(); return; }
message.textContent = 'Correct.';
showQuestion(result);
} catch (error) { message.textContent = error.message; }
});
question.append(form); input.focus();
};
start.addEventListener('click', async () => {
try { showQuestion(await api('api/reading-quiz/start', { m: P.get('m') })); start.hidden = true; message.textContent = ''; }
catch (error) { message.textContent = error.message; }
});
if (!access.quizAvailable) { start.hidden = true; message.textContent = 'Fewer than five questions match your completed chapters. Please return during reading hours.'; }
window.addEventListener('pagehide', () => {
if (challenge) navigator.sendBeacon('api/reading-quiz/abandon', new Blob([JSON.stringify({ challenge })], { type: 'application/json' }));
});
setInterval(async () => {
try { if ((await api('api/reading-access?' + qsa({ m: P.get('m') }))).allowed) location.reload(); } catch { /* keep the gate visible */ }
}, 15000);
return;
}
const payload = await response.json();
if (!response.ok) throw new Error(payload.error || response.statusText);
D = payload;
}
catch (e) { canvas.textContent = ''; canvas.append(h('p', { class: 'loading' }, e.message + ' · ', h('a', { href: './' }, 'Library'))); return; }
// An already-open chapter also closes when a window ends or an unlock expires.
setInterval(async () => {
try { if (!(await api('api/reading-access?' + qsa({ m: P.get('m') }))).allowed) location.reload(); } catch { /* transient check failure */ }
}, 15000);
const S = me.settings, rtl = D.direction === 'rtl', ttb = D.direction === 'ttb';
const GAP = ttb || S.flowPageGap === false ? 0 : 8, END_H = 280;
document.title = `${D.chapter.label} · ${D.manga.title}`;