From 1e96208533cf39c295203cf74fde88a39fdbabd5 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Fri, 20 Mar 2026 08:59:29 -0400 Subject: [PATCH] feat: add progress bar, auto-hide header, active thumb tracking to series reader Co-Authored-By: Claude Sonnet 4.6 --- app/static/js/reader.js | 220 ++++++++++++++++++++++++++++++++++++++ app/static/style.css | 49 +++++++-- app/templates/reader.html | 154 ++------------------------ 3 files changed, 268 insertions(+), 155 deletions(-) create mode 100644 app/static/js/reader.js diff --git a/app/static/js/reader.js b/app/static/js/reader.js new file mode 100644 index 0000000..dd59086 --- /dev/null +++ b/app/static/js/reader.js @@ -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(); +}); diff --git a/app/static/style.css b/app/static/style.css index 5e47f35..25c49fa 100644 --- a/app/static/style.css +++ b/app/static/style.css @@ -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 */ diff --git a/app/templates/reader.html b/app/templates/reader.html index 83b58c2..5ec6d04 100644 --- a/app/templates/reader.html +++ b/app/templates/reader.html @@ -2,6 +2,8 @@ {% block title %}{{ series_name }} - Reader{% endblock %} {% block content %} + +
@@ -56,7 +58,6 @@ alt="Page {{ page.page_number }}" loading="lazy" data-image-id="{{ page.image.id }}"> -
{{ page.page_number }} / {{ total_pages }}
{% endfor %} {% else %} @@ -84,151 +85,10 @@
+ {% endblock %}