feat: add progress bar, auto-hide header, active thumb tracking to series reader
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,220 @@
|
||||
// /app/static/js/reader.js
|
||||
'use strict';
|
||||
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
const { startPage, totalPages } = window.readerConfig || { startPage: 1, totalPages: 1 };
|
||||
|
||||
// Elements
|
||||
// #readerContent is the actual scroll container (overflow-y: auto)
|
||||
const readerContent = document.getElementById('readerContent');
|
||||
const pageJumpSelect = document.getElementById('pageJumpSelect');
|
||||
const floatingIndicator = document.getElementById('floatingPageIndicator');
|
||||
const currentPageNum = document.getElementById('currentPageNum');
|
||||
const readerNav = document.getElementById('readerNav');
|
||||
const readerHeader = document.querySelector('.reader-header');
|
||||
const toggleNavBtn = document.getElementById('toggleNavBtn');
|
||||
const closeNavBtn = document.getElementById('closeNavBtn');
|
||||
const navThumbs = document.querySelectorAll('.reader-nav-thumb');
|
||||
const scrollToTopBtn = document.getElementById('scrollToTopBtn');
|
||||
const scrollToBottomBtn = document.getElementById('scrollToBottomBtn');
|
||||
const pages = document.querySelectorAll('.reader-page');
|
||||
const progressBar = document.getElementById('readerProgressBar');
|
||||
|
||||
let currentPage = startPage;
|
||||
let isNavOpen = false;
|
||||
let headerHideTimeout = null;
|
||||
|
||||
// -------------------------
|
||||
// Scroll to starting page
|
||||
// -------------------------
|
||||
if (startPage > 1) {
|
||||
const targetPage = document.getElementById('page-' + startPage);
|
||||
if (targetPage) {
|
||||
setTimeout(() => targetPage.scrollIntoView({ behavior: 'auto' }), 100);
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------
|
||||
// Progress bar
|
||||
// -------------------------
|
||||
function updateProgressBar() {
|
||||
if (!progressBar || !readerContent) return;
|
||||
const scrollTop = readerContent.scrollTop;
|
||||
const scrollHeight = readerContent.scrollHeight - readerContent.clientHeight;
|
||||
const pct = scrollHeight > 0 ? (scrollTop / scrollHeight) * 100 : 0;
|
||||
progressBar.style.width = `${Math.min(pct, 100)}%`;
|
||||
}
|
||||
|
||||
// -------------------------
|
||||
// Auto-hide header
|
||||
// -------------------------
|
||||
function showHeader() {
|
||||
if (!readerHeader) return;
|
||||
readerHeader.classList.remove('hidden');
|
||||
clearTimeout(headerHideTimeout);
|
||||
}
|
||||
|
||||
function scheduleHideHeader() {
|
||||
clearTimeout(headerHideTimeout);
|
||||
headerHideTimeout = setTimeout(() => {
|
||||
if (readerHeader) readerHeader.classList.add('hidden');
|
||||
}, 2500);
|
||||
}
|
||||
|
||||
// -------------------------
|
||||
// Current page tracking
|
||||
// -------------------------
|
||||
function updateCurrentPage() {
|
||||
if (!readerContent) return;
|
||||
const scrollTop = readerContent.scrollTop;
|
||||
const viewportCenter = scrollTop + (readerContent.clientHeight / 3);
|
||||
|
||||
let newCurrentPage = currentPage;
|
||||
pages.forEach(page => {
|
||||
const pageTop = page.offsetTop;
|
||||
const pageBottom = pageTop + page.offsetHeight;
|
||||
if (viewportCenter >= pageTop && viewportCenter < pageBottom) {
|
||||
newCurrentPage = parseInt(page.dataset.page);
|
||||
}
|
||||
});
|
||||
|
||||
if (newCurrentPage !== currentPage) {
|
||||
currentPage = newCurrentPage;
|
||||
|
||||
// Update floating indicator
|
||||
if (currentPageNum) currentPageNum.textContent = currentPage;
|
||||
|
||||
// Update jump select
|
||||
if (pageJumpSelect) pageJumpSelect.value = currentPage;
|
||||
|
||||
// Update active nav thumb + scroll it into view
|
||||
navThumbs.forEach(thumb => {
|
||||
const isActive = parseInt(thumb.dataset.page) === currentPage;
|
||||
thumb.classList.toggle('active', isActive);
|
||||
if (isActive) {
|
||||
thumb.scrollIntoView({ block: 'nearest', behavior: 'smooth' });
|
||||
}
|
||||
});
|
||||
|
||||
// Update URL without reload
|
||||
const url = new URL(window.location);
|
||||
url.searchParams.set('page', currentPage);
|
||||
history.replaceState({}, '', url);
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------
|
||||
// Scroll handler
|
||||
// -------------------------
|
||||
let scrollTimeout = null;
|
||||
if (readerContent) {
|
||||
readerContent.addEventListener('scroll', () => {
|
||||
updateProgressBar();
|
||||
showHeader();
|
||||
scheduleHideHeader();
|
||||
|
||||
if (scrollTimeout) return;
|
||||
scrollTimeout = setTimeout(() => {
|
||||
updateCurrentPage();
|
||||
scrollTimeout = null;
|
||||
}, 50);
|
||||
});
|
||||
}
|
||||
|
||||
// Show header on mouse move
|
||||
document.addEventListener('mousemove', showHeader);
|
||||
|
||||
// -------------------------
|
||||
// Jump to page
|
||||
// -------------------------
|
||||
if (pageJumpSelect) {
|
||||
pageJumpSelect.addEventListener('change', (e) => {
|
||||
const pageNum = parseInt(e.target.value);
|
||||
const targetPage = document.getElementById('page-' + pageNum);
|
||||
if (targetPage) targetPage.scrollIntoView({ behavior: 'smooth' });
|
||||
});
|
||||
}
|
||||
|
||||
// -------------------------
|
||||
// Sidebar toggle
|
||||
// -------------------------
|
||||
if (toggleNavBtn) {
|
||||
toggleNavBtn.addEventListener('click', () => {
|
||||
isNavOpen = !isNavOpen;
|
||||
readerNav.classList.toggle('open', isNavOpen);
|
||||
});
|
||||
}
|
||||
|
||||
if (closeNavBtn) {
|
||||
closeNavBtn.addEventListener('click', () => {
|
||||
isNavOpen = false;
|
||||
readerNav.classList.remove('open');
|
||||
});
|
||||
}
|
||||
|
||||
// Click thumbnail to jump
|
||||
navThumbs.forEach(thumb => {
|
||||
thumb.addEventListener('click', () => {
|
||||
const pageNum = parseInt(thumb.dataset.page);
|
||||
const targetPage = document.getElementById('page-' + pageNum);
|
||||
if (targetPage) targetPage.scrollIntoView({ behavior: 'smooth' });
|
||||
if (window.innerWidth < 768) {
|
||||
isNavOpen = false;
|
||||
readerNav.classList.remove('open');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// -------------------------
|
||||
// Quick nav buttons
|
||||
// -------------------------
|
||||
if (scrollToTopBtn) {
|
||||
scrollToTopBtn.addEventListener('click', () => {
|
||||
readerContent.scrollTo({ top: 0, behavior: 'smooth' });
|
||||
});
|
||||
}
|
||||
|
||||
if (scrollToBottomBtn) {
|
||||
scrollToBottomBtn.addEventListener('click', () => {
|
||||
readerContent.scrollTo({ top: readerContent.scrollHeight, behavior: 'smooth' });
|
||||
});
|
||||
}
|
||||
|
||||
// -------------------------
|
||||
// Keyboard navigation
|
||||
// -------------------------
|
||||
document.addEventListener('keydown', (e) => {
|
||||
if (e.target.tagName === 'INPUT' || e.target.tagName === 'SELECT') return;
|
||||
showHeader();
|
||||
|
||||
switch (e.key) {
|
||||
case 'Home':
|
||||
e.preventDefault();
|
||||
readerContent.scrollTo({ top: 0, behavior: 'smooth' });
|
||||
break;
|
||||
case 'End':
|
||||
e.preventDefault();
|
||||
readerContent.scrollTo({ top: readerContent.scrollHeight, behavior: 'smooth' });
|
||||
break;
|
||||
case 'PageUp':
|
||||
e.preventDefault();
|
||||
readerContent.scrollBy({ top: -readerContent.clientHeight * 0.9, behavior: 'smooth' });
|
||||
break;
|
||||
case 'PageDown':
|
||||
case ' ':
|
||||
e.preventDefault();
|
||||
readerContent.scrollBy({ top: readerContent.clientHeight * 0.9, behavior: 'smooth' });
|
||||
break;
|
||||
case 'n':
|
||||
isNavOpen = !isNavOpen;
|
||||
readerNav.classList.toggle('open', isNavOpen);
|
||||
break;
|
||||
}
|
||||
});
|
||||
|
||||
// -------------------------
|
||||
// Initial state
|
||||
// -------------------------
|
||||
updateCurrentPage();
|
||||
updateProgressBar();
|
||||
});
|
||||
+41
-8
@@ -2376,6 +2376,12 @@ body.select-mode .gallery-infinite-container {
|
||||
background: var(--panel);
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
|
||||
flex-shrink: 0;
|
||||
transition: opacity 0.3s ease, transform 0.3s ease;
|
||||
}
|
||||
.reader-header.hidden {
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transform: translateY(-4px);
|
||||
}
|
||||
|
||||
.reader-header-left {
|
||||
@@ -2623,20 +2629,47 @@ body.select-mode .gallery-infinite-container {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
/* Reading progress bar */
|
||||
.reader-progress-bar {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
height: 2px;
|
||||
width: 0%;
|
||||
background: var(--btn-primary);
|
||||
z-index: 1100;
|
||||
transition: width 0.1s linear;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* Active nav thumbnail */
|
||||
.reader-nav-thumb.active {
|
||||
border-left: 3px solid var(--btn-primary);
|
||||
opacity: 1;
|
||||
}
|
||||
.reader-nav-thumb:not(.active) {
|
||||
opacity: 0.5;
|
||||
transition: opacity 0.2s ease;
|
||||
}
|
||||
.reader-nav-thumb:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* Floating page indicator */
|
||||
.floating-page-indicator {
|
||||
position: fixed;
|
||||
bottom: 1.5rem;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
background: rgba(0, 0, 0, 0.85);
|
||||
color: white;
|
||||
padding: 0.5rem 1rem;
|
||||
right: 1.5rem;
|
||||
padding: 4px 10px;
|
||||
background: rgba(0, 0, 0, 0.6);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
border-radius: 999px;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 500;
|
||||
box-shadow: var(--shadow-2);
|
||||
font-size: 0.8rem;
|
||||
color: var(--text-muted);
|
||||
z-index: 100;
|
||||
pointer-events: none;
|
||||
backdrop-filter: blur(4px);
|
||||
-webkit-backdrop-filter: blur(4px);
|
||||
}
|
||||
|
||||
/* Quick nav buttons */
|
||||
|
||||
+7
-147
@@ -2,6 +2,8 @@
|
||||
{% block title %}{{ series_name }} - Reader{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<!-- Reading progress bar -->
|
||||
<div id="readerProgressBar" class="reader-progress-bar"></div>
|
||||
<div class="reader-container">
|
||||
<!-- Reader Header -->
|
||||
<div class="reader-header">
|
||||
@@ -56,7 +58,6 @@
|
||||
alt="Page {{ page.page_number }}"
|
||||
loading="lazy"
|
||||
data-image-id="{{ page.image.id }}">
|
||||
<div class="page-indicator">{{ page.page_number }} / {{ total_pages }}</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
@@ -84,151 +85,10 @@
|
||||
</div>
|
||||
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
const readerContent = document.getElementById('readerContent');
|
||||
const pageJumpSelect = document.getElementById('pageJumpSelect');
|
||||
const floatingIndicator = document.getElementById('floatingPageIndicator');
|
||||
const currentPageNum = document.getElementById('currentPageNum');
|
||||
const readerNav = document.getElementById('readerNav');
|
||||
const toggleNavBtn = document.getElementById('toggleNavBtn');
|
||||
const closeNavBtn = document.getElementById('closeNavBtn');
|
||||
const navThumbs = document.querySelectorAll('.reader-nav-thumb');
|
||||
const scrollToTopBtn = document.getElementById('scrollToTopBtn');
|
||||
const scrollToBottomBtn = document.getElementById('scrollToBottomBtn');
|
||||
const pages = document.querySelectorAll('.reader-page');
|
||||
|
||||
const startPage = {{ start_page }};
|
||||
let currentPage = startPage;
|
||||
let isNavOpen = false;
|
||||
|
||||
// Scroll to starting page on load
|
||||
if (startPage > 1) {
|
||||
const targetPage = document.getElementById('page-' + startPage);
|
||||
if (targetPage) {
|
||||
setTimeout(() => {
|
||||
targetPage.scrollIntoView({ behavior: 'auto' });
|
||||
}, 100);
|
||||
}
|
||||
}
|
||||
|
||||
// Track current page while scrolling
|
||||
function updateCurrentPage() {
|
||||
const scrollTop = readerContent.scrollTop;
|
||||
const viewportHeight = readerContent.clientHeight;
|
||||
const viewportCenter = scrollTop + (viewportHeight / 3);
|
||||
|
||||
let newCurrentPage = 1;
|
||||
pages.forEach(page => {
|
||||
const pageTop = page.offsetTop;
|
||||
const pageBottom = pageTop + page.offsetHeight;
|
||||
if (viewportCenter >= pageTop && viewportCenter < pageBottom) {
|
||||
newCurrentPage = parseInt(page.dataset.page);
|
||||
}
|
||||
});
|
||||
|
||||
if (newCurrentPage !== currentPage) {
|
||||
currentPage = newCurrentPage;
|
||||
currentPageNum.textContent = currentPage;
|
||||
pageJumpSelect.value = currentPage;
|
||||
|
||||
// Update nav thumb highlight
|
||||
navThumbs.forEach(thumb => {
|
||||
thumb.classList.toggle('active', parseInt(thumb.dataset.page) === currentPage);
|
||||
});
|
||||
|
||||
// Update URL without reload
|
||||
const url = new URL(window.location);
|
||||
url.searchParams.set('page', currentPage);
|
||||
history.replaceState({}, '', url);
|
||||
}
|
||||
}
|
||||
|
||||
// Throttled scroll handler
|
||||
let scrollTimeout;
|
||||
readerContent.addEventListener('scroll', () => {
|
||||
if (scrollTimeout) return;
|
||||
scrollTimeout = setTimeout(() => {
|
||||
updateCurrentPage();
|
||||
scrollTimeout = null;
|
||||
}, 50);
|
||||
});
|
||||
|
||||
// Jump to page from select
|
||||
pageJumpSelect.addEventListener('change', (e) => {
|
||||
const pageNum = parseInt(e.target.value);
|
||||
const targetPage = document.getElementById('page-' + pageNum);
|
||||
if (targetPage) {
|
||||
targetPage.scrollIntoView({ behavior: 'smooth' });
|
||||
}
|
||||
});
|
||||
|
||||
// Toggle navigation sidebar
|
||||
toggleNavBtn.addEventListener('click', () => {
|
||||
isNavOpen = !isNavOpen;
|
||||
readerNav.classList.toggle('open', isNavOpen);
|
||||
});
|
||||
|
||||
closeNavBtn.addEventListener('click', () => {
|
||||
isNavOpen = false;
|
||||
readerNav.classList.remove('open');
|
||||
});
|
||||
|
||||
// Click thumbnail to jump
|
||||
navThumbs.forEach(thumb => {
|
||||
thumb.addEventListener('click', () => {
|
||||
const pageNum = parseInt(thumb.dataset.page);
|
||||
const targetPage = document.getElementById('page-' + pageNum);
|
||||
if (targetPage) {
|
||||
targetPage.scrollIntoView({ behavior: 'smooth' });
|
||||
}
|
||||
// Close nav on mobile
|
||||
if (window.innerWidth < 768) {
|
||||
isNavOpen = false;
|
||||
readerNav.classList.remove('open');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Quick nav buttons
|
||||
scrollToTopBtn.addEventListener('click', () => {
|
||||
readerContent.scrollTo({ top: 0, behavior: 'smooth' });
|
||||
});
|
||||
|
||||
scrollToBottomBtn.addEventListener('click', () => {
|
||||
readerContent.scrollTo({ top: readerContent.scrollHeight, behavior: 'smooth' });
|
||||
});
|
||||
|
||||
// Keyboard navigation
|
||||
document.addEventListener('keydown', (e) => {
|
||||
if (e.target.tagName === 'INPUT' || e.target.tagName === 'SELECT') return;
|
||||
|
||||
switch (e.key) {
|
||||
case 'Home':
|
||||
e.preventDefault();
|
||||
readerContent.scrollTo({ top: 0, behavior: 'smooth' });
|
||||
break;
|
||||
case 'End':
|
||||
e.preventDefault();
|
||||
readerContent.scrollTo({ top: readerContent.scrollHeight, behavior: 'smooth' });
|
||||
break;
|
||||
case 'PageUp':
|
||||
e.preventDefault();
|
||||
readerContent.scrollBy({ top: -readerContent.clientHeight * 0.9, behavior: 'smooth' });
|
||||
break;
|
||||
case 'PageDown':
|
||||
case ' ':
|
||||
e.preventDefault();
|
||||
readerContent.scrollBy({ top: readerContent.clientHeight * 0.9, behavior: 'smooth' });
|
||||
break;
|
||||
case 'n':
|
||||
isNavOpen = !isNavOpen;
|
||||
readerNav.classList.toggle('open', isNavOpen);
|
||||
break;
|
||||
}
|
||||
});
|
||||
|
||||
// Initial state
|
||||
updateCurrentPage();
|
||||
});
|
||||
window.readerConfig = {
|
||||
startPage: {{ start_page }},
|
||||
totalPages: {{ total_pages }}
|
||||
};
|
||||
</script>
|
||||
<script src="{{ url_for('static', filename='js/reader.js') }}"></script>
|
||||
{% endblock %}
|
||||
|
||||
Reference in New Issue
Block a user