Files
deploy-test/templates/reader.html
2026-04-01 23:24:41 -05:00

1742 lines
63 KiB
HTML
Raw 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.0, user-scalable=no" />
<title>Animex Reader</title>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css" />
<style>
:root {
--background-color: #121212;
--text-color: #eaeaea;
--accent-color: #ff9500;
--accent-hover: #ffaa33;
--header-bg: rgba(18, 18, 18, 0.88);
--footer-bg: rgba(18, 18, 18, 0.92);
--border-color: rgba(255, 255, 255, 0.1);
--panel-bg: #1c1c1e;
--zoom: 1;
}
*, *::before, *::after { box-sizing: border-box; }
body {
margin: 0;
background-color: var(--background-color);
color: var(--text-color);
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
overflow: hidden;
}
/* ── Scrollbar ── */
::-webkit-scrollbar { width: 6px; height: 6px; }
::-webkit-scrollbar-track { background: #111; }
::-webkit-scrollbar-thumb { background: #3a3a3a; border-radius: 3px; }
::-webkit-scrollbar-thumb:hover { background: #555; }
/* ── Header ── */
#reader-header {
position: fixed;
top: 0; left: 0; right: 0;
z-index: 100;
background-color: var(--header-bg);
backdrop-filter: blur(14px);
-webkit-backdrop-filter: blur(14px);
border-bottom: 1px solid var(--border-color);
display: flex;
justify-content: space-between;
align-items: center;
height: 60px;
padding: 0 16px;
gap: 12px;
transition: transform 0.3s ease-in-out;
box-shadow: 0 4px 16px rgba(0,0,0,0.35);
}
#reader-header.hidden { transform: translateY(-100%); }
.header-left {
display: flex;
align-items: center;
gap: 10px;
min-width: 0;
flex: 1;
}
.header-right {
display: flex;
align-items: center;
gap: 8px;
flex-shrink: 0;
}
/* Title block */
.header-titles {
display: flex;
flex-direction: column;
justify-content: center;
min-width: 0;
gap: 0;
}
.header-titles h1 {
font-size: 15px;
margin: 0;
font-weight: 600;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
line-height: 1.25;
}
.header-titles p {
font-size: 12px;
margin: 0;
color: #999;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
line-height: 1.25;
}
/* ── Icon Buttons (back, chapters) ── */
.icon-circle-btn {
background: none;
border: none;
color: var(--text-color);
font-size: 18px;
cursor: pointer;
padding: 8px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
transition: background 0.2s;
}
.icon-circle-btn:hover { background: rgba(255,255,255,0.1); }
/* ── Segmented switcher ── */
.mode-switcher {
background: rgba(255,255,255,0.07);
border-radius: 8px;
padding: 3px;
display: flex;
border: 1px solid rgba(255,255,255,0.08);
gap: 2px;
}
.mode-switcher button {
background: transparent;
border: none;
color: #999;
padding: 5px 10px;
border-radius: 6px;
cursor: pointer;
font-size: 12px;
font-weight: 500;
transition: all 0.18s;
white-space: nowrap;
display: flex;
align-items: center;
gap: 5px;
}
.mode-switcher button:hover { color: #fff; background: rgba(255,255,255,0.08); }
.mode-switcher button.active {
background: rgba(255,255,255,0.16);
color: #fff;
font-weight: 600;
box-shadow: 0 1px 3px rgba(0,0,0,0.25);
}
/* Fit switcher — icon-only, smaller */
#fit-switcher button { padding: 5px 9px; font-size: 13px; }
/* ── Direction button ── */
#direction-btn {
background: rgba(255,255,255,0.07);
border: 1px solid rgba(255,255,255,0.1);
color: #aaa;
padding: 5px 9px;
border-radius: 6px;
cursor: pointer;
font-size: 11px;
font-weight: 700;
letter-spacing: 0.8px;
transition: all 0.2s;
flex-shrink: 0;
}
#direction-btn:hover { color: #fff; background: rgba(255,255,255,0.14); }
/* Hide direction btn in scroll mode */
.view-scroll #direction-btn { display: none !important; }
/* ── Next Chapter btn ── */
#next-chapter-btn {
background: var(--accent-color);
color: #fff;
border: none;
padding: 7px 13px;
border-radius: 6px;
font-weight: 600;
font-size: 12px;
cursor: pointer;
display: none;
align-items: center;
gap: 5px;
box-shadow: 0 2px 8px rgba(255,149,0,0.3);
transition: all 0.2s;
flex-shrink: 0;
}
#next-chapter-btn:hover { background: var(--accent-hover); transform: translateY(-1px); }
/* ── Central Loader ── */
#loader {
position: fixed;
top: 50%; left: 50%;
transform: translate(-50%, -50%);
width: 40px; height: 40px;
border: 4px solid #2a2a2a;
border-top-color: var(--accent-color);
border-radius: 50%;
animation: spin-center 0.8s linear infinite;
z-index: 60;
}
@keyframes spin-center {
to { transform: translate(-50%, -50%) rotate(360deg); }
}
/* ── Main Image Container ── */
#image-container {
height: 100vh;
width: 100vw;
background-color: #000;
position: relative;
}
/* ── SCROLL LAYOUT ── */
.view-scroll #image-container {
overflow-y: scroll;
overflow-x: hidden;
-webkit-overflow-scrolling: touch;
}
/* Scroll + fit-both (default) */
.view-scroll img {
display: block;
margin: 0 auto;
height: auto;
}
.view-scroll.fit-both img {
width: calc(min(900px, 100%) * var(--zoom));
max-width: none;
}
.view-scroll.fit-width img {
width: calc(100% * var(--zoom));
max-width: none;
}
.view-scroll.fit-height img {
height: calc(100vh * var(--zoom));
width: auto;
max-width: none;
}
/* ── SINGLE LAYOUT ── */
.view-single #image-container {
display: flex;
overflow-x: hidden;
overflow-y: hidden;
}
.view-single img {
flex-shrink: 0;
user-select: none;
-webkit-user-drag: none;
}
.view-single.fit-both img {
width: 100vw;
height: 100vh;
object-fit: contain;
transform: scale(var(--zoom));
transform-origin: center center;
}
.view-single.fit-width img {
width: 100vw;
height: auto;
object-fit: unset;
transform: scale(var(--zoom));
transform-origin: top center;
}
.view-single.fit-height img {
height: 100vh;
width: auto;
object-fit: unset;
transform: scale(var(--zoom));
transform-origin: center center;
}
/* ── DOUBLE LAYOUT ── */
.view-double #image-container {
display: flex;
overflow-x: hidden;
overflow-y: hidden;
}
.view-double .spread {
display: flex;
width: 100vw;
height: 100vh;
flex-shrink: 0;
align-items: center;
justify-content: center;
overflow: hidden;
gap: 0;
}
.view-double.dir-rtl .spread { flex-direction: row-reverse; }
.view-double .spread img {
display: block;
flex: 0 0 auto;
min-width: 0;
user-select: none;
-webkit-user-drag: none;
}
/* fit-both: each page fills its half by height, capped at 50vw wide */
.view-double.fit-both .spread img {
width: auto;
height: calc(100vh * var(--zoom));
max-width: 50vw;
object-fit: contain;
}
.view-double.fit-width .spread img {
width: calc(50vw * var(--zoom));
height: auto;
max-width: none;
object-fit: unset;
}
.view-double.fit-height .spread img {
height: calc(100vh * var(--zoom));
width: auto;
max-width: 50vw;
object-fit: unset;
}
/* ── Navigation Overlay (Click Zones) ── */
.nav-overlay {
position: fixed;
top: 0; left: 0;
height: 100%; width: 100%;
z-index: 50;
display: none;
pointer-events: none;
}
.view-single .nav-overlay,
.view-double .nav-overlay { display: block; }
#nav-left, #nav-right, #nav-center {
position: absolute;
top: 0; height: 100%;
pointer-events: all;
}
#nav-left { left: 0; width: 30%; cursor: w-resize; }
#nav-right { right: 0; width: 30%; cursor: e-resize; }
#nav-center { left: 30%; width: 40%; cursor: pointer; }
/* ── Footer ── */
#reader-footer {
position: fixed;
bottom: 0; left: 0; right: 0;
z-index: 100;
background-color: var(--footer-bg);
backdrop-filter: blur(14px);
-webkit-backdrop-filter: blur(14px);
border-top: 1px solid var(--border-color);
height: 50px;
display: flex;
align-items: center;
padding: 0 14px;
gap: 10px;
transition: transform 0.3s ease-in-out;
}
#reader-footer.hidden { transform: translateY(100%); }
.footer-left {
display: flex;
align-items: center;
gap: 6px;
flex-shrink: 0;
}
.footer-center {
flex: 1;
display: flex;
align-items: center;
}
.footer-right {
flex-shrink: 0;
}
/* Footer spinner */
#footer-loader {
width: 16px; height: 16px;
border: 2px solid #333;
border-top-color: var(--accent-color);
border-radius: 50%;
animation: spin-simple 0.8s linear infinite;
flex-shrink: 0;
}
#footer-loader.hidden { display: none; }
@keyframes spin-simple { to { transform: rotate(360deg); } }
/* Zoom controls */
.zoom-btn {
background: rgba(255,255,255,0.07);
border: 1px solid rgba(255,255,255,0.1);
color: var(--text-color);
width: 26px; height: 26px;
border-radius: 5px;
cursor: pointer;
font-size: 15px;
display: flex;
align-items: center;
justify-content: center;
line-height: 1;
transition: background 0.2s;
padding: 0;
flex-shrink: 0;
}
.zoom-btn:hover { background: rgba(255,255,255,0.15); }
.zoom-btn:disabled { opacity: 0.3; cursor: not-allowed; }
#zoom-label {
font-size: 11px;
color: #999;
min-width: 36px;
text-align: center;
font-variant-numeric: tabular-nums;
font-weight: 500;
flex-shrink: 0;
}
/* Progress bar */
#page-progress {
width: 100%;
height: 6px;
border-radius: 3px;
cursor: pointer;
-webkit-appearance: none;
appearance: none;
}
#page-progress::-webkit-progress-bar {
background-color: rgba(255,255,255,0.12);
border-radius: 3px;
}
#page-progress::-webkit-progress-value {
background-color: var(--accent-color);
border-radius: 3px;
transition: width 0.1s linear;
}
/* Page indicator */
#page-indicator {
font-size: 12px;
color: #ccc;
font-variant-numeric: tabular-nums;
font-weight: 500;
min-width: 64px;
text-align: right;
white-space: nowrap;
}
/* ── Chapter Panel ── */
#chapter-panel-backdrop {
position: fixed;
inset: 0;
background: rgba(0,0,0,0.55);
z-index: 200;
backdrop-filter: blur(3px);
-webkit-backdrop-filter: blur(3px);
opacity: 1;
transition: opacity 0.28s ease;
}
#chapter-panel-backdrop.hidden {
opacity: 0;
pointer-events: none;
}
#chapter-panel {
position: fixed;
top: 0; right: 0;
width: min(360px, 92vw);
height: 100vh;
background: var(--panel-bg);
border-left: 1px solid rgba(255,255,255,0.1);
z-index: 201;
display: flex;
flex-direction: column;
transform: translateX(0);
transition: transform 0.28s cubic-bezier(0.4,0,0.2,1);
box-shadow: -8px 0 32px rgba(0,0,0,0.6);
}
#chapter-panel.hidden { transform: translateX(100%); }
.panel-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 18px;
height: 60px;
border-bottom: 1px solid rgba(255,255,255,0.08);
flex-shrink: 0;
}
.panel-header-title {
font-size: 15px;
font-weight: 700;
letter-spacing: 0.2px;
}
#close-panel-btn {
background: none;
border: none;
color: #888;
font-size: 20px;
cursor: pointer;
padding: 6px 8px;
border-radius: 6px;
line-height: 1;
transition: all 0.2s;
}
#close-panel-btn:hover { background: rgba(255,255,255,0.08); color: #fff; }
#chapter-list {
overflow-y: auto;
flex: 1;
padding: 6px 0;
}
.chapter-item {
display: flex;
align-items: center;
gap: 10px;
padding: 11px 18px;
cursor: pointer;
transition: background 0.15s;
border-left: 3px solid transparent;
}
.chapter-item:hover { background: rgba(255,255,255,0.05); }
.chapter-item.active {
background: rgba(255,149,0,0.1);
border-left-color: var(--accent-color);
}
.ch-number {
font-weight: 600;
font-size: 13px;
color: var(--text-color);
white-space: nowrap;
flex-shrink: 0;
}
.ch-title {
font-size: 12px;
color: #777;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
flex: 1;
}
.ch-check {
font-size: 10px;
flex-shrink: 0;
opacity: 0;
color: var(--accent-color);
transition: opacity 0.2s;
}
.chapter-item.finished .ch-number { color: #666; }
.chapter-item.finished .ch-title { color: #555; }
.chapter-item.finished .ch-check { opacity: 1; }
/* Progress badge */
.ch-badge {
flex-shrink: 0;
font-size: 10px;
font-weight: 600;
padding: 2px 7px;
border-radius: 10px;
white-space: nowrap;
letter-spacing: 0.3px;
}
.ch-badge.badge-read {
background: rgba(80,200,120,0.15);
color: #50c878;
}
.ch-badge.badge-reading {
background: rgba(255,149,0,0.15);
color: var(--accent-color);
}
/* ── Toast ── */
#toast {
position: fixed;
bottom: 68px;
left: 50%;
transform: translateX(-50%);
background: rgba(28,28,30,0.96);
color: var(--text-color);
padding: 9px 18px;
border-radius: 8px;
font-size: 13px;
z-index: 400;
pointer-events: none;
border: 1px solid rgba(255,255,255,0.1);
box-shadow: 0 6px 20px rgba(0,0,0,0.5);
opacity: 1;
transition: opacity 0.3s ease;
white-space: nowrap;
}
#toast.hidden { opacity: 0; }
/* ── Responsive ── */
@media (min-width: 768px) {
.header-titles h1 { font-size: 16px; }
.header-titles p { font-size: 13px; }
#page-progress { height: 8px; }
}
@media (max-width: 600px) {
.mode-switcher button { padding: 5px 7px; font-size: 11px; gap: 3px; }
#next-chapter-btn { padding: 6px 10px; font-size: 11px; }
#direction-btn { padding: 5px 7px; font-size: 10px; }
}
</style>
</head>
<body class="view-single fit-both dir-ltr">
<!-- ══ HEADER ══ -->
<header id="reader-header">
<div class="header-left">
<button id="back-btn" class="icon-circle-btn" aria-label="Go back">
<i class="fa fa-arrow-left"></i>
</button>
<button id="chapters-btn" class="icon-circle-btn" aria-label="Chapter list" title="Chapter List">
<i class="fa fa-list"></i>
</button>
<div class="header-titles">
<h1 id="manga-title">Loading Series…</h1>
<p id="chapter-details">Loading Chapter…</p>
</div>
</div>
<div class="header-right">
<!-- Layout: Scroll | Single | Double -->
<div class="mode-switcher" id="layout-switcher">
<button id="layout-scroll" title="Scroll (Webtoon)"><i class="fa fa-scroll"></i> Scroll</button>
<button id="layout-single" class="active" title="Single Page"><i class="fa fa-book-open"></i> Single</button>
<button id="layout-double" title="Double Page"><i class="fa fa-book"></i> Double</button>
</div>
<!-- Fit: Width | Height | Both -->
<div class="mode-switcher" id="fit-switcher">
<button id="fit-width" title="Fit Width"><i class="fa fa-arrows-left-right"></i></button>
<button id="fit-height" title="Fit Height"><i class="fa fa-arrows-up-down"></i></button>
<button id="fit-both" class="active" title="Fit Both (Contain)"><i class="fa fa-compress"></i></button>
</div>
<!-- Direction (hidden in scroll mode) -->
<button id="direction-btn" title="Toggle Reading Direction">LTR</button>
<!-- Next Chapter -->
<button id="next-chapter-btn">
Next <i class="fa fa-chevron-right"></i>
</button>
</div>
</header>
<!-- ══ CENTERED LOADER ══ -->
<div id="loader"></div>
<!-- ══ IMAGE CONTAINER ══ -->
<main id="image-container"></main>
<!-- ══ NAV OVERLAY (paged modes) ══ -->
<div class="nav-overlay">
<div id="nav-left" title="Previous Page"></div>
<div id="nav-center" title="Toggle UI"></div>
<div id="nav-right" title="Next Page"></div>
</div>
<!-- ══ FOOTER ══ -->
<footer id="reader-footer">
<div class="footer-left">
<div id="footer-loader" class="hidden"></div>
<button id="zoom-out" class="zoom-btn" title="Zoom Out"></button>
<span id="zoom-label">100%</span>
<button id="zoom-in" class="zoom-btn" title="Zoom In">+</button>
</div>
<div class="footer-center">
<progress id="page-progress" value="0" max="100"></progress>
</div>
<div class="footer-right">
<span id="page-indicator">0 / 0</span>
</div>
</footer>
<!-- ══ CHAPTER PANEL ══ -->
<div id="chapter-panel-backdrop" class="hidden"></div>
<div id="chapter-panel" class="hidden">
<div class="panel-header">
<span class="panel-header-title">Chapters</span>
<button id="close-panel-btn" aria-label="Close chapter list"></button>
</div>
<div id="chapter-list"></div>
</div>
<!-- ══ TOAST ══ -->
<div id="toast" class="hidden"></div>
<script>
document.addEventListener("DOMContentLoaded", () => {
// ═══════════════════════════════════════════════════════════
// ELEMENTS
// ═══════════════════════════════════════════════════════════
const container = document.getElementById("image-container");
const loader = document.getElementById("loader");
const footerLoader = document.getElementById("footer-loader");
const header = document.getElementById("reader-header");
const footer = document.getElementById("reader-footer");
const mangaTitleEl = document.getElementById("manga-title");
const chapterDetailsEl= document.getElementById("chapter-details");
const backBtn = document.getElementById("back-btn");
const chaptersBtn = document.getElementById("chapters-btn");
const nextChapterBtn = document.getElementById("next-chapter-btn");
const pageProgress = document.getElementById("page-progress");
const pageIndicator = document.getElementById("page-indicator");
const zoomOutBtn = document.getElementById("zoom-out");
const zoomInBtn = document.getElementById("zoom-in");
const zoomLabel = document.getElementById("zoom-label");
const directionBtn = document.getElementById("direction-btn");
const chapterPanel = document.getElementById("chapter-panel");
const panelBackdrop = document.getElementById("chapter-panel-backdrop");
const chapterListEl = document.getElementById("chapter-list");
const closePanelBtn = document.getElementById("close-panel-btn");
const toast = document.getElementById("toast");
const navLeft = document.getElementById("nav-left");
const navRight = document.getElementById("nav-right");
const navCenter = document.getElementById("nav-center");
const layoutScrollBtn = document.getElementById("layout-scroll");
const layoutSingleBtn = document.getElementById("layout-single");
const layoutDoubleBtn = document.getElementById("layout-double");
const fitWidthBtn = document.getElementById("fit-width");
const fitHeightBtn = document.getElementById("fit-height");
const fitBothBtn = document.getElementById("fit-both");
// ═══════════════════════════════════════════════════════════
// CONSTANTS / KEYS
// ═══════════════════════════════════════════════════════════
const serverUrl = ``;
const LAYOUT_KEY = "reader_layout_mode"; // "scroll"|"single"|"double"
const FIT_KEY = "reader_fit_mode"; // "fit-width"|"fit-height"|"fit-both"
const DIRECTION_KEY = "reader_direction"; // "ltr"|"rtl"
const ZOOM_KEY = "reader_zoom_level"; // number 0.53.0
const HISTORY_KEY = "reading_history";
const ZOOM_MIN = 0.5;
const ZOOM_MAX = 3.0;
const ZOOM_STEP = 0.1;
// ═══════════════════════════════════════════════════════════
// STATE
// ═══════════════════════════════════════════════════════════
let imageLinks = [];
let currentPage = 0; // always an image index (0-based)
let source, mangaId, chapterId;
let chapterData = null;
let allChapters = []; // [{id, number, title}, ...]
let currentChapterIndex= -1;
let layoutMode = "single";
let fitMode = "fit-both";
let direction = "ltr";
let zoomLevel = 1.0;
let toastTimeout = null;
let panelOpen = false;
// ═══════════════════════════════════════════════════════════
// INIT
// ═══════════════════════════════════════════════════════════
function init() {
const pathParts = window.location.pathname.split("/");
// Expected: /read/{source}/{mangaId}/{chapterId}
if (pathParts.length >= 5) {
source = pathParts[2];
mangaId = pathParts[3];
chapterId = pathParts[4];
} else {
console.error("Invalid URL structure");
return;
}
// Load preferences
layoutMode = localStorage.getItem(LAYOUT_KEY) || "single";
fitMode = localStorage.getItem(FIT_KEY) || "fit-both";
direction = localStorage.getItem(DIRECTION_KEY) || "ltr";
zoomLevel = parseFloat(localStorage.getItem(ZOOM_KEY)) || 1.0;
zoomLevel = Math.min(ZOOM_MAX, Math.max(ZOOM_MIN, zoomLevel));
// Apply body classes immediately
document.body.className = `view-${layoutMode} ${fitMode} dir-${direction}`;
applyZoom(zoomLevel, false);
updateLayoutButtons();
updateFitButtons();
updateDirectionButton();
// Fetch
fetchMangaTitle();
fetchAllChapters().then(() => fetchChapterDetails());
fetchImages();
setupControls();
// popstate (browser back/forward within reader)
window.addEventListener("popstate", (e) => {
if (e.state && e.state.chapterId && e.state.chapterId !== chapterId) {
loadChapter(e.state.chapterId, true /* isPop */);
}
});
// Replace current history entry with state so popstate works
history.replaceState({ source, mangaId, chapterId }, "", window.location.href);
}
// ═══════════════════════════════════════════════════════════
// HISTORY MANAGEMENT
// ═══════════════════════════════════════════════════════════
/**
* History structure in localStorage:
* [
* {
* mangaId: "123",
* source: "mangadex",
* chapters: {
* "chapter-uuid": {
* state: "ongoing" | "finished",
* page: 4,
* timestamp: 123456789,
* source: "mangadex"
* }
* }
* }
* ]
*/
function getHistory() {
try { return JSON.parse(localStorage.getItem(HISTORY_KEY)) || []; }
catch (e) { return []; }
}
function saveHistory(history) {
localStorage.setItem(HISTORY_KEY, JSON.stringify(history));
}
function updateHistory() {
if (imageLinks.length === 0) return;
let hist = getHistory();
let seriesEntry = hist.find(h => h.mangaId === mangaId && h.source === source);
if (!seriesEntry) {
seriesEntry = { mangaId, source, chapters: {} };
hist.push(seriesEntry);
}
const isFinished = currentPage >= imageLinks.length - 1;
seriesEntry.chapters[chapterId] = {
state: isFinished ? "finished" : "ongoing",
page: currentPage,
timestamp: Date.now(),
source
};
saveHistory(hist);
}
function restoreReadingProgress() {
const hist = getHistory();
const seriesEntry = hist.find(h => h.mangaId === mangaId && h.source === source);
if (seriesEntry?.chapters?.[chapterId]) {
const record = seriesEntry.chapters[chapterId];
if (record.state === "finished") {
// Chapter already finished → start fresh from page 0
currentPage = 0;
} else if (record.page > 0 && record.page < imageLinks.length) {
currentPage = record.page;
// In double mode, snap currentPage to even (start of spread)
if (layoutMode === "double" && currentPage % 2 !== 0) {
currentPage = Math.max(0, currentPage - 1);
}
}
}
// Apply position
if (layoutMode !== "scroll") {
updatePagination();
} else {
setTimeout(() => {
const imgs = getAllImages();
if (imgs[currentPage]) {
imgs[currentPage].scrollIntoView({ behavior: "auto", block: "start" });
}
}, 100);
}
updateFooter();
}
// ═══════════════════════════════════════════════════════════
// CHAPTER MANAGEMENT
// ═══════════════════════════════════════════════════════════
async function fetchAllChapters() {
const url = source === "mangadex"
? `${serverUrl}/mangadex/manga/${mangaId}/chapters`
: `${serverUrl}/chapters/${mangaId}`;
try {
const resp = await fetch(url);
if (!resp.ok) return;
const data = await resp.json();
// Support both flat `data.chapters` and `data.modules` (keyed by source)
let rawChapters = [];
if (Array.isArray(data.chapters)) {
rawChapters = data.chapters;
} else if (data.modules && typeof data.modules === "object") {
const preferred = data.modules[source];
if (Array.isArray(preferred) && preferred.length > 0) {
rawChapters = preferred;
} else {
const firstKey = Object.keys(data.modules)[0];
rawChapters = Array.isArray(data.modules[firstKey]) ? data.modules[firstKey] : [];
}
}
allChapters = rawChapters.map(c => {
if (source === "mangadex") {
return {
id: c.id,
number: c.attributes?.chapter ?? "?",
title: c.attributes?.title || ""
};
} else {
return {
id: c.id ?? c.chapter_number,
number: c.chapter_number ?? c.id,
title: c.title || ""
};
}
});
allChapters.reverse();
currentChapterIndex = allChapters.findIndex(c => String(c.id) === String(chapterId));
renderChapterPanel();
} catch (e) {
console.error("fetchAllChapters failed:", e);
}
}
/**
* In-page chapter switch — no redirect.
* isPop = true when called from popstate (skip pushState).
*/
async function loadChapter(newChapterId, isPop = false) {
if (String(newChapterId) === String(chapterId)) return;
// Save progress for the chapter we're leaving
updateHistory();
showToast("Loading chapter…");
// Reset state
imageLinks = [];
currentPage = 0;
chapterId = String(newChapterId);
currentChapterIndex = allChapters.findIndex(c => String(c.id) === String(newChapterId));
chapterData = null;
// Reset UI
container.innerHTML = "";
pageIndicator.textContent = "0 / 0";
pageProgress.value = 0;
pageProgress.max = 100;
nextChapterBtn.style.display = "none";
footerLoader.classList.add("hidden");
// Show centered loader again
loader.style.display = "";
// Update URL (unless triggered by browser nav)
if (!isPop) {
history.pushState(
{ source, mangaId, chapterId },
"",
`/read/${source}/${mangaId}/${newChapterId}`
);
}
// Re-highlight panel (if open)
updatePanelHighlight();
// Fetch new chapter data (non-blocking title re-fetch not needed — stays same manga)
fetchChapterDetails();
await fetchImages();
}
function openPanel() {
renderChapterPanel();
chapterPanel.classList.remove("hidden");
panelBackdrop.classList.remove("hidden");
panelOpen = true;
}
function closePanel() {
chapterPanel.classList.add("hidden");
panelBackdrop.classList.add("hidden");
panelOpen = false;
}
function renderChapterPanel() {
chapterListEl.innerHTML = "";
if (allChapters.length === 0) {
const empty = document.createElement("div");
empty.style.cssText = "padding:24px 18px;color:#666;font-size:13px;";
empty.textContent = "No chapters available.";
chapterListEl.appendChild(empty);
return;
}
const hist = getHistory();
const seriesEntry = hist.find(h => h.mangaId === mangaId && h.source === source);
allChapters.forEach(ch => {
const item = document.createElement("div");
item.className = "chapter-item";
item.dataset.chId = String(ch.id);
if (String(ch.id) === String(chapterId)) item.classList.add("active");
// Look up history by string AND number key for safety
const chRecord = seriesEntry?.chapters?.[ch.id]
?? seriesEntry?.chapters?.[String(ch.id)];
if (chRecord?.state === "finished") item.classList.add("finished");
const numSpan = document.createElement("span");
numSpan.className = "ch-number";
numSpan.textContent = `Ch. ${ch.number}`;
const titleSpan = document.createElement("span");
titleSpan.className = "ch-title";
titleSpan.textContent = ch.title || "";
const check = document.createElement("i");
check.className = "fa fa-check ch-check";
item.appendChild(numSpan);
item.appendChild(titleSpan);
// ── Progress badge ──
if (chRecord) {
const badge = document.createElement("span");
badge.className = "ch-badge";
if (chRecord.state === "finished") {
badge.classList.add("badge-read");
badge.textContent = "Read";
} else if (chRecord.state === "ongoing") {
badge.classList.add("badge-reading");
// Show page progress if we know the total (only available for current chapter)
const total = (String(ch.id) === String(chapterId)) ? imageLinks.length : 0;
if (total > 0) {
badge.textContent = `Pg ${chRecord.page + 1}/${total}`;
} else {
badge.textContent = chRecord.page > 0 ? `Pg ${chRecord.page + 1}` : "Reading";
}
}
item.appendChild(badge);
}
item.appendChild(check);
item.addEventListener("click", () => {
if (String(ch.id) !== String(chapterId)) {
loadChapter(ch.id);
}
});
chapterListEl.appendChild(item);
});
// Scroll active item into view
requestAnimationFrame(() => {
const active = chapterListEl.querySelector(".chapter-item.active");
if (active) active.scrollIntoView({ block: "nearest" });
});
}
function updatePanelHighlight() {
const items = chapterListEl.querySelectorAll(".chapter-item");
items.forEach(item => {
item.classList.toggle("active", item.dataset.chId === String(chapterId));
});
const active = chapterListEl.querySelector(".chapter-item.active");
if (active) active.scrollIntoView({ block: "nearest" });
}
// ═══════════════════════════════════════════════════════════
// FETCHING LOGIC
// ═══════════════════════════════════════════════════════════
async function fetchMangaTitle() {
const url = source === "mangadex"
? `${serverUrl}/mangadex/manga/${mangaId}`
: `https://api.jikan.moe/v4/manga/${mangaId}`;
try {
const res = await fetch(url);
const data = await res.json();
if (source === "mangadex") {
mangaTitleEl.textContent =
data.attributes?.title?.en ||
Object.values(data.attributes?.title || {})[0] ||
"Unknown Title";
} else {
mangaTitleEl.textContent = data.data?.title || "Unknown Title";
}
} catch (e) {
mangaTitleEl.textContent = "Reader";
}
}
async function fetchChapterDetails() {
// ── Legacy (non-mangadex) ──
if (source !== "mangadex") {
const ch = allChapters[currentChapterIndex];
if (ch) {
const title = ch.title ? ` · ${ch.title}` : "";
chapterDetailsEl.textContent = `Ch. ${ch.number}${title}`;
} else {
chapterDetailsEl.textContent = `Ch. ${chapterId}`;
}
// Wire next chapter button via allChapters
const nextCh = allChapters[currentChapterIndex + 1];
if (nextCh) {
nextChapterBtn.style.display = "flex";
nextChapterBtn.onclick = () => loadChapter(nextCh.id);
} else {
nextChapterBtn.style.display = "none";
}
return;
}
// ── MangaDex ──
try {
const url = `${serverUrl}/mangadex/manga/${mangaId}/chapter-nav-details/${chapterId}`;
const response = await fetch(url);
if (!response.ok) throw new Error("Nav fetch failed");
const navData = await response.json();
chapterData = navData.current_chapter;
if (chapterData?.attributes) {
const chNum = chapterData.attributes.chapter;
const title = chapterData.attributes.title;
chapterDetailsEl.textContent =
`Ch. ${chNum}${title ? " · " + title : ""}`;
} else {
chapterDetailsEl.textContent = `Ch. ${chapterId}`;
}
if (navData.next_chapter_id) {
nextChapterBtn.style.display = "flex";
nextChapterBtn.onclick = () => loadChapter(navData.next_chapter_id);
} else {
nextChapterBtn.style.display = "none";
}
} catch (error) {
console.warn("Nav details error:", error);
chapterDetailsEl.textContent = `Ch. ${chapterId}`;
// Fallback: use allChapters for next btn
const nextCh = allChapters[currentChapterIndex + 1];
if (nextCh) {
nextChapterBtn.style.display = "flex";
nextChapterBtn.onclick = () => loadChapter(nextCh.id);
}
}
}
async function fetchImages() {
const url = source === "mangadex"
? `${serverUrl}/mangadex/chapter/${chapterId}`
: `${serverUrl}/retrieve/${mangaId}/${chapterId}`;
try {
const response = await fetch(url);
if (!response.ok) throw new Error("Image fetch failed");
imageLinks = await response.json();
if (!imageLinks || imageLinks.length === 0) throw new Error("No images found");
await preloadInitialImages();
} catch (error) {
console.error(error);
loader.style.borderTopColor = "red";
chapterDetailsEl.textContent = "Error loading images.";
}
}
// ═══════════════════════════════════════════════════════════
// PROXY DETECTION — probe once, then route all images
// ═══════════════════════════════════════════════════════════
// null = unknown (probe pending), true = direct works, false = must proxy
let _directAccessOk = null;
// Queue of callbacks waiting on the probe result
let _probeCallbacks = [];
/**
* Run (or await) the one-time connectivity probe.
* Resolves with true (direct) or false (proxy fallback).
*/
async function probeDirectAccess(sampleUrl) {
if (_directAccessOk !== null) return _directAccessOk;
return new Promise(resolve => {
_probeCallbacks.push(resolve);
// Only the first caller kicks off the real probe
if (_probeCallbacks.length > 1) return;
const probe = new Image();
const timer = setTimeout(() => settle(false), 8000); // 8 s timeout
function settle(ok) {
clearTimeout(timer);
_directAccessOk = ok;
console.log(`[reader] direct-access probe: ${ok ? "✓ direct" : "✗ using proxy"}`);
_probeCallbacks.forEach(cb => cb(ok));
_probeCallbacks = [];
}
probe.onload = () => settle(true);
probe.onerror = () => settle(false);
// Cache-bust so the browser actually fires a network request
probe.src = sampleUrl + (sampleUrl.includes("?") ? "&" : "?") + "_probe=" + Date.now();
});
}
function proxyUrl(src) {
if (!src || src.startsWith("/")) return src;
return `${serverUrl}/proxy-image?url=${encodeURIComponent(src)}`;
}
/**
* Return the URL to use for an image.
* Uses cached probe result — call after probeDirectAccess() has settled.
*/
function resolvedUrl(src) {
if (!src || src.startsWith("/")) return src;
return _directAccessOk ? src : proxyUrl(src);
}
/**
* Attach src to an img element with automatic proxy retry on error.
* @param {HTMLImageElement} img
* @param {string} rawSrc — the original (un-proxied) URL
*/
function setImgSrc(img, rawSrc) {
if (!rawSrc) return;
const direct = _directAccessOk !== false; // use direct if probe passed or still unknown
img.src = direct && !rawSrc.startsWith("/") ? rawSrc : proxyUrl(rawSrc);
img.dataset.rawSrc = rawSrc; // store original for retry
img.onerror = function retryWithProxy() {
img.onerror = null; // prevent infinite loop
const proxied = proxyUrl(rawSrc);
if (img.src !== proxied) {
console.warn("[reader] direct load failed, retrying via proxy:", rawSrc);
_directAccessOk = false; // cache result for future images
img.src = proxied;
}
};
}
/** Build the DOM skeleton for images (flat or spread-based). */
function buildImageDOM() {
container.innerHTML = "";
if (layoutMode === "double") {
for (let i = 0; i < imageLinks.length; i += 2) {
const spread = document.createElement("div");
spread.className = "spread";
const img1 = document.createElement("img");
img1.loading = "lazy";
spread.appendChild(img1);
if (i + 1 < imageLinks.length) {
const img2 = document.createElement("img");
img2.loading = "lazy";
spread.appendChild(img2);
}
container.appendChild(spread);
}
} else {
imageLinks.forEach(() => {
const img = document.createElement("img");
img.loading = "lazy";
container.appendChild(img);
});
}
}
/** Returns ALL img elements regardless of spread wrapper depth. */
function getAllImages() {
return Array.from(container.querySelectorAll("img"));
}
async function preloadInitialImages() {
buildImageDOM();
const allImgs = getAllImages();
const toPreload = allImgs.slice(0, 4);
// ── ONE-TIME CONNECTIVITY PROBE ──
// Try the first raw image URL directly; fall back to proxy for everything if it fails.
if (imageLinks[0]) {
await probeDirectAccess(imageLinks[0]);
}
// ── Auto-guesser: detect tall (webtoon-style) images ──
if (toPreload[0] && imageLinks[0]) {
const tempImg = new Image();
tempImg.src = resolvedUrl(imageLinks[0]);
await new Promise(r => {
tempImg.onload = () => {
const isTall = tempImg.height > tempImg.width * 2;
const userSetLayout = localStorage.getItem(LAYOUT_KEY);
// Guesser only overrides layout to scroll (never changes single↔double)
// And is suppressed entirely if user has set a preference
if (isTall && !userSetLayout) {
setLayout("scroll", true /* isAutoGuess */);
// Rebuild DOM since layout changed
buildImageDOM();
}
r();
};
tempImg.onerror = r;
});
}
// Re-query after potential DOM rebuild
const imgs = getAllImages();
const firstFour = imgs.slice(0, 4);
// Preload first 4 images (probe already settled, so setImgSrc picks the right path)
const promises = firstFour.map((img, idx) =>
new Promise(resolve => {
img.onload = resolve;
// onerror handled inside setImgSrc (proxy retry); resolve after that too
const origOnerror = null;
img.addEventListener("load", resolve, { once: true });
img.addEventListener("error", resolve, { once: true });
setImgSrc(img, imageLinks[idx]);
})
);
await Promise.all(promises);
// ── First images ready: move loader to footer ──
loader.style.display = "none";
if (imageLinks.length > 4) {
footerLoader.classList.remove("hidden");
}
// Restore reading position
restoreReadingProgress();
// Load the rest in the background
loadRemainingImages();
}
function loadRemainingImages() {
const imgs = getAllImages();
const remaining = imgs.slice(4);
if (remaining.length === 0) {
footerLoader.classList.add("hidden");
return;
}
let loadedCount = 0;
remaining.forEach((img, idx) => {
const done = () => {
loadedCount++;
if (loadedCount >= remaining.length) {
footerLoader.classList.add("hidden");
}
};
// Use resolved URL with automatic proxy-retry on individual failures
img.addEventListener("load", done, { once: true });
img.addEventListener("error", done, { once: true });
setImgSrc(img, imageLinks[idx + 4]);
});
}
/**
* Rebuild the image container DOM when switching between
* double ↔ single/scroll (where spread wrappers are needed).
* Reuses already-loaded image srcs to avoid re-fetching.
*/
function rebuildImageContainer() {
if (imageLinks.length === 0) return;
// Preserve original (raw) srcs so we don't double-proxy
const existingRawSrcs = getAllImages().map(img => img.dataset.rawSrc || img.src || "");
buildImageDOM();
const newImgs = getAllImages();
newImgs.forEach((img, i) => {
if (existingRawSrcs[i]) setImgSrc(img, existingRawSrcs[i]);
});
// Restore scroll position after rebuild
if (layoutMode === "scroll") {
setTimeout(() => {
const imgs = getAllImages();
if (imgs[currentPage]) {
imgs[currentPage].scrollIntoView({ behavior: "auto", block: "start" });
}
}, 50);
} else {
// Snap currentPage to even in double mode
if (layoutMode === "double" && currentPage % 2 !== 0) {
currentPage = Math.max(0, currentPage - 1);
}
updatePagination();
}
updateFooter();
}
// ═══════════════════════════════════════════════════════════
// LAYOUT & APPEARANCE
// ═══════════════════════════════════════════════════════════
function setLayout(mode, isAutoGuess = false) {
const prevMode = layoutMode;
const needRebuild = (mode === "double" || prevMode === "double") && mode !== prevMode;
layoutMode = mode;
document.body.classList.remove("view-scroll", "view-single", "view-double");
document.body.classList.add(`view-${mode}`);
updateLayoutButtons();
updateDirectionButton(); // show/hide based on scroll vs paged
if (!isAutoGuess) {
localStorage.setItem(LAYOUT_KEY, mode);
}
if (imageLinks.length > 0) {
if (needRebuild) {
rebuildImageContainer();
} else if (mode === "scroll") {
const imgs = getAllImages();
if (imgs[currentPage]) {
imgs[currentPage].scrollIntoView({ behavior: "auto", block: "start" });
}
} else {
if (mode === "double" && currentPage % 2 !== 0) {
currentPage = Math.max(0, currentPage - 1);
}
updatePagination();
}
}
updateFooter();
}
function setFit(mode) {
document.body.classList.remove("fit-width", "fit-height", "fit-both");
document.body.classList.add(mode);
fitMode = mode;
localStorage.setItem(FIT_KEY, mode);
updateFitButtons();
}
function setDirection(dir) {
document.body.classList.remove("dir-ltr", "dir-rtl");
document.body.classList.add(`dir-${dir}`);
direction = dir;
localStorage.setItem(DIRECTION_KEY, dir);
updateDirectionButton();
}
function applyZoom(level, save = true) {
zoomLevel = Math.min(ZOOM_MAX, Math.max(ZOOM_MIN, level));
document.documentElement.style.setProperty("--zoom", zoomLevel);
zoomLabel.textContent = `${Math.round(zoomLevel * 100)}%`;
zoomOutBtn.disabled = zoomLevel <= ZOOM_MIN;
zoomInBtn.disabled = zoomLevel >= ZOOM_MAX;
if (save) localStorage.setItem(ZOOM_KEY, zoomLevel);
}
function updateLayoutButtons() {
layoutScrollBtn.classList.toggle("active", layoutMode === "scroll");
layoutSingleBtn.classList.toggle("active", layoutMode === "single");
layoutDoubleBtn.classList.toggle("active", layoutMode === "double");
}
function updateFitButtons() {
fitWidthBtn.classList.toggle("active", fitMode === "fit-width");
fitHeightBtn.classList.toggle("active", fitMode === "fit-height");
fitBothBtn.classList.toggle("active", fitMode === "fit-both");
}
function updateDirectionButton() {
if (layoutMode === "scroll") {
directionBtn.style.display = "none";
} else {
directionBtn.style.display = "";
directionBtn.textContent = direction.toUpperCase();
}
}
// ═══════════════════════════════════════════════════════════
// NAVIGATION
// ═══════════════════════════════════════════════════════════
function updatePagination() {
if (layoutMode === "double") {
const spreadIndex = Math.floor(currentPage / 2);
container.scrollLeft = spreadIndex * window.innerWidth;
} else {
container.scrollLeft = currentPage * window.innerWidth;
}
updateHistory();
}
function updateFooter() {
if (imageLinks.length === 0) {
pageIndicator.textContent = "0 / 0";
pageProgress.value = 0;
pageProgress.max = 100;
return;
}
if (layoutMode === "scroll") {
// Handled by scroll listener (updateScrollProgress)
} else if (layoutMode === "double") {
const pg2 = Math.min(currentPage + 2, imageLinks.length);
const display = currentPage + 1 === pg2
? `${pg2} / ${imageLinks.length}`
: `${currentPage + 1}${pg2} / ${imageLinks.length}`;
pageIndicator.textContent = display;
const spreadIndex = Math.floor(currentPage / 2);
const totalSpreads = Math.ceil(imageLinks.length / 2);
pageProgress.value = spreadIndex + 1;
pageProgress.max = totalSpreads;
} else {
pageIndicator.textContent = `${currentPage + 1} / ${imageLinks.length}`;
pageProgress.value = currentPage + 1;
pageProgress.max = imageLinks.length;
}
}
function updateScrollProgress() {
if (imageLinks.length === 0) return;
const scrollableH = container.scrollHeight - container.clientHeight;
const pct = scrollableH > 0 ? (container.scrollTop / scrollableH) * 100 : 0;
pageProgress.value = pct;
pageProgress.max = 100;
// Detect current page by visibility
const imgs = getAllImages();
let visible = currentPage;
for (let i = 0; i < imgs.length; i++) {
const rect = imgs[i].getBoundingClientRect();
if (rect.bottom > 0 && rect.top < window.innerHeight / 2) {
visible = i;
}
}
if (visible !== currentPage) {
currentPage = visible;
pageIndicator.textContent = `${currentPage + 1} / ${imageLinks.length}`;
updateHistory();
}
}
/**
* direction: +1 = story-forward, -1 = story-backward
* (caller already accounts for RTL when mapping clicks/keys)
*/
function changePage(direction_) {
if (layoutMode === "scroll") {
// In scroll mode, arrow keys scroll by one viewport height
container.scrollBy({ top: direction_ * window.innerHeight * 0.9, behavior: "smooth" });
return;
}
const step = layoutMode === "double" ? 2 : 1;
const newPage = currentPage + direction_ * step;
if (newPage < 0) {
handleChapterEdge(-1);
return;
}
if (newPage >= imageLinks.length) {
handleChapterEdge(1);
return;
}
currentPage = newPage;
updatePagination();
updateFooter();
}
function handleChapterEdge(storyDir) {
// storyDir: +1 = next chapter, -1 = prev chapter
if (storyDir > 0) {
const next = allChapters[currentChapterIndex + 1];
if (next) {
loadChapter(next.id);
} else {
showToast("You've reached the last chapter!");
}
} else {
const prev = allChapters[currentChapterIndex - 1];
if (prev) {
loadChapter(prev.id);
} else {
showToast("This is the first chapter!");
}
}
}
function toggleUI() {
header.classList.toggle("hidden");
footer.classList.toggle("hidden");
}
// ═══════════════════════════════════════════════════════════
// TOAST
// ═══════════════════════════════════════════════════════════
function showToast(msg, duration = 2800) {
toast.textContent = msg;
toast.classList.remove("hidden");
clearTimeout(toastTimeout);
toastTimeout = setTimeout(() => toast.classList.add("hidden"), duration);
}
// ═══════════════════════════════════════════════════════════
// CONTROLS SETUP (called once)
// ═══════════════════════════════════════════════════════════
function setupControls() {
// ── Layout switcher ──
layoutScrollBtn.addEventListener("click", () => setLayout("scroll"));
layoutSingleBtn.addEventListener("click", () => setLayout("single"));
layoutDoubleBtn.addEventListener("click", () => setLayout("double"));
// ── Fit switcher ──
fitWidthBtn.addEventListener("click", () => setFit("fit-width"));
fitHeightBtn.addEventListener("click", () => setFit("fit-height"));
fitBothBtn.addEventListener("click", () => setFit("fit-both"));
// ── Direction ──
directionBtn.addEventListener("click", () => {
setDirection(direction === "ltr" ? "rtl" : "ltr");
});
// ── Zoom buttons ──
zoomOutBtn.addEventListener("click", () => applyZoom(zoomLevel - ZOOM_STEP));
zoomInBtn.addEventListener("click", () => applyZoom(zoomLevel + ZOOM_STEP));
// ── Ctrl+Scroll / Pinch zoom ──
container.addEventListener("wheel", (e) => {
if (e.ctrlKey) {
e.preventDefault();
const delta = e.deltaY > 0 ? -ZOOM_STEP : ZOOM_STEP;
applyZoom(zoomLevel + delta);
}
}, { passive: false });
// ── Scroll listener (scroll mode progress) ──
container.addEventListener("scroll", () => {
if (layoutMode === "scroll") updateScrollProgress();
});
// ── Chapter panel ──
chaptersBtn.addEventListener("click", openPanel);
closePanelBtn.addEventListener("click", closePanel);
panelBackdrop.addEventListener("click", closePanel);
// ── Nav overlay (paged modes) ──
// In RTL mode, the visual left/right clicks are swapped in story direction
navLeft.addEventListener("click", () => {
const storyDir = direction === "rtl" ? 1 : -1;
changePage(storyDir);
});
navRight.addEventListener("click", () => {
const storyDir = direction === "rtl" ? -1 : 1;
changePage(storyDir);
});
navCenter.addEventListener("click", toggleUI);
// ── Progress bar click (paged modes) ──
pageProgress.addEventListener("click", (e) => {
if (imageLinks.length === 0) return;
if (layoutMode === "scroll") {
const ratio = e.offsetX / pageProgress.offsetWidth;
const scrollableH = container.scrollHeight - container.clientHeight;
container.scrollTo({ top: ratio * scrollableH, behavior: "smooth" });
} else {
const ratio = e.offsetX / pageProgress.offsetWidth;
const target = Math.floor(ratio * imageLinks.length);
const snapped = layoutMode === "double" ? target - (target % 2) : target;
currentPage = Math.max(0, Math.min(snapped, imageLinks.length - 1));
updatePagination();
updateFooter();
}
});
// ── Keyboard ──
document.addEventListener("keydown", (e) => {
// Don't fire if user is typing somewhere
if (e.target.tagName === "INPUT" || e.target.tagName === "TEXTAREA") return;
const rtl = direction === "rtl" && layoutMode !== "scroll";
if (e.key === "ArrowRight" || e.key === "ArrowDown") {
e.preventDefault();
changePage(rtl ? -1 : 1);
}
if (e.key === "ArrowLeft" || e.key === "ArrowUp") {
e.preventDefault();
changePage(rtl ? 1 : -1);
}
if (e.key === " " && !e.shiftKey) {
e.preventDefault();
changePage(1);
}
if (e.key === " " && e.shiftKey) {
e.preventDefault();
changePage(-1);
}
if (e.key === "Escape" && panelOpen) {
closePanel();
}
if (e.key === "f" || e.key === "F") {
toggleUI();
}
});
// ── Resize: re-snap scroll position so paged modes stay aligned ──
let resizeTimer = null;
window.addEventListener("resize", () => {
clearTimeout(resizeTimer);
resizeTimer = setTimeout(() => {
if (layoutMode !== "scroll" && imageLinks.length > 0) {
updatePagination();
}
}, 50);
});
// ── Back button ──
backBtn.addEventListener("click", () => {
if (window.parent && window.parent !== window) {
window.parent.postMessage("close-reader-modal", "*");
} else if (window.history.length > 1) {
window.history.back();
} else {
window.location.href = "/";
}
});
}
// ── Kick it off ──
init();
});
</script>
</body>
</html>