Files
kjvstudy.org/kjvstudy_org/templates/offline.html
T
kennethreitz 2cdfaa52cd Redesign offline page with cleaner layout
- Centered hero section with status badge
- Prominent status card with icon
- Clean download section with gradient background
- Quick access grid with larger icon cards
- Improved Bible reader section with styled controls
- Technical details collapsed by default
- Simplified cached URL list (limit 500 per category)
- Cleaner, more modern styling throughout
- Better dark mode support

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-30 02:15:36 -05:00

898 lines
34 KiB
HTML

{% extends "base.html" %}
{% block title %}Offline Mode - KJV Study{% endblock %}
{% block extra_head %}
<style>
.offline-hero {
text-align: center;
padding: 2rem 0;
margin-bottom: 2rem;
border-bottom: 1px solid var(--border-color, #ddd);
}
.offline-hero h1 {
margin: 0 0 0.5rem 0;
font-size: 2.5rem;
}
.offline-hero .subtitle {
font-size: 1.1rem;
color: var(--text-secondary, #666);
margin: 0;
}
.badge {
display: inline-block;
padding: 0.25rem 0.75rem;
border-radius: 20px;
font-size: 0.8rem;
font-weight: 600;
margin-left: 0.75rem;
vertical-align: middle;
}
.badge-offline { background: var(--error-color, #c41e3a); color: white; }
.badge-online { background: var(--success-color, #4a7c59); color: white; }
/* Status Card */
.status-card {
display: flex;
align-items: center;
gap: 1.5rem;
padding: 1.5rem 2rem;
border-radius: 12px;
margin-bottom: 2rem;
background: var(--bg-color, #fffff8);
border: 2px solid var(--border-color, #ddd);
}
.status-card.status-ok {
border-color: var(--success-color, #4a7c59);
background: linear-gradient(135deg, #f0fdf4 0%, #dcfce7 100%);
}
.status-card.status-warning {
border-color: #f59e0b;
background: linear-gradient(135deg, #fffbeb 0%, #fef3c7 100%);
}
.status-card.status-error {
border-color: var(--error-color, #c41e3a);
background: linear-gradient(135deg, #fef2f2 0%, #fee2e2 100%);
}
[data-theme="dark"] .status-card.status-ok {
background: linear-gradient(135deg, #14532d 0%, #166534 100%);
}
[data-theme="dark"] .status-card.status-warning {
background: linear-gradient(135deg, #78350f 0%, #92400e 100%);
}
[data-theme="dark"] .status-card.status-error {
background: linear-gradient(135deg, #7f1d1d 0%, #991b1b 100%);
}
.status-icon {
font-size: 3rem;
line-height: 1;
}
.status-content {
flex: 1;
}
.status-title {
font-weight: 700;
font-size: 1.25rem;
margin-bottom: 0.25rem;
}
.status-detail {
color: var(--text-secondary, #666);
font-size: 0.95rem;
line-height: 1.5;
}
.status-detail code {
background: rgba(0,0,0,0.1);
padding: 0.1rem 0.4rem;
border-radius: 4px;
font-size: 0.85em;
}
[data-theme="dark"] .status-detail code {
background: rgba(255,255,255,0.1);
}
/* Download Section */
.download-section {
background: linear-gradient(135deg, #f8fafc 0%, #f1f5f9 100%);
padding: 2rem;
border-radius: 12px;
margin-bottom: 2rem;
border: 1px solid var(--border-color, #ddd);
}
[data-theme="dark"] .download-section {
background: linear-gradient(135deg, #1e293b 0%, #0f172a 100%);
}
.download-section h2 {
margin: 0 0 0.5rem 0;
font-size: 1.5rem;
}
.download-section p {
margin: 0 0 1.5rem 0;
color: var(--text-secondary, #666);
}
.download-btn {
display: inline-flex;
align-items: center;
gap: 0.5rem;
padding: 1rem 2rem;
font-size: 1.1rem;
font-weight: 600;
border: none;
border-radius: 8px;
background: var(--success-color, #4a7c59);
color: white;
cursor: pointer;
transition: all 0.2s;
}
.download-btn:hover {
filter: brightness(1.1);
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(74, 124, 89, 0.3);
}
.download-btn:disabled {
opacity: 0.7;
cursor: not-allowed;
transform: none;
box-shadow: none;
}
.download-progress {
margin-top: 1.5rem;
display: none;
}
.download-progress.active {
display: block;
}
.progress-bar {
width: 100%;
height: 12px;
background: var(--border-color, #ddd);
border-radius: 6px;
overflow: hidden;
margin-bottom: 0.75rem;
}
.progress-fill {
height: 100%;
background: linear-gradient(90deg, var(--success-color, #4a7c59) 0%, #6aa87a 100%);
width: 0%;
transition: width 0.3s ease;
border-radius: 6px;
}
.progress-text {
font-size: 0.9rem;
color: var(--text-secondary, #666);
}
.cache-complete {
color: var(--success-color, #4a7c59);
font-weight: 600;
}
/* Quick Access Grid */
.quick-access {
margin-bottom: 2rem;
}
.quick-access h2 {
margin: 0 0 1rem 0;
}
.quick-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
gap: 1rem;
}
.quick-card {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.5rem;
padding: 1.25rem;
background: var(--bg-color, #fffff8);
border: 1px solid var(--border-color, #ddd);
border-radius: 12px;
text-decoration: none;
color: var(--text-color, #111);
transition: all 0.2s;
}
.quick-card:hover {
border-color: var(--success-color, #4a7c59);
transform: translateY(-3px);
box-shadow: 0 8px 24px rgba(0,0,0,0.1);
}
.quick-card-icon {
font-size: 2rem;
}
.quick-card-label {
font-weight: 600;
font-size: 1rem;
}
/* Bible Reader */
.reader-section {
background: var(--bg-color, #fffff8);
border: 1px solid var(--border-color, #ddd);
border-radius: 12px;
padding: 2rem;
margin-bottom: 2rem;
}
.reader-section h2 {
margin: 0 0 0.5rem 0;
}
.reader-section > p {
margin: 0 0 1.5rem 0;
color: var(--text-secondary, #666);
}
.reader-controls {
display: flex;
gap: 1rem;
flex-wrap: wrap;
align-items: center;
margin-bottom: 1.5rem;
}
.reader-controls select {
padding: 0.75rem 1rem;
font-size: 1rem;
border: 2px solid var(--border-color, #ddd);
border-radius: 8px;
background: var(--bg-color, #fffff8);
color: var(--text-color, #111);
font-family: inherit;
cursor: pointer;
}
.reader-controls select:focus {
outline: none;
border-color: var(--success-color, #4a7c59);
}
.nav-buttons {
display: flex;
gap: 0.5rem;
}
.nav-btn {
padding: 0.75rem 1.25rem;
border: 2px solid var(--border-color, #ddd);
border-radius: 8px;
background: var(--bg-color, #fffff8);
color: var(--text-color, #111);
cursor: pointer;
font-size: 1rem;
font-weight: 500;
transition: all 0.2s;
}
.nav-btn:hover:not(:disabled) {
border-color: var(--success-color, #4a7c59);
background: var(--success-color, #4a7c59);
color: white;
}
.nav-btn:disabled {
opacity: 0.4;
cursor: not-allowed;
}
#chapter-content {
display: none;
line-height: 2;
font-size: 1.15rem;
}
#chapter-content.active {
display: block;
}
#chapter-content h3 {
margin: 0 0 1rem 0;
padding-bottom: 0.5rem;
border-bottom: 1px solid var(--border-color, #ddd);
}
#chapter-content p {
margin: 0.75rem 0;
}
.verse-num {
font-size: 0.8rem;
vertical-align: super;
margin-right: 0.25rem;
color: var(--success-color, #4a7c59);
font-weight: 600;
}
.keyboard-hint {
font-size: 0.85rem;
color: var(--text-secondary, #666);
margin-top: 1rem;
padding-top: 1rem;
border-top: 1px solid var(--border-color, #ddd);
}
/* Technical Details */
.tech-details {
margin-top: 3rem;
}
.tech-details summary {
cursor: pointer;
color: var(--text-secondary, #666);
font-size: 0.9rem;
padding: 0.75rem 0;
display: flex;
align-items: center;
gap: 0.5rem;
}
.tech-details summary:hover {
color: var(--text-color, #111);
}
.tech-details[open] summary {
margin-bottom: 1rem;
}
.tech-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 1rem;
margin-bottom: 2rem;
}
.tech-item {
background: var(--border-color, #ddd);
padding: 1rem;
border-radius: 8px;
}
.tech-item dt {
font-weight: 600;
font-size: 0.85rem;
color: var(--text-secondary, #666);
margin-bottom: 0.25rem;
}
.tech-item dd {
margin: 0;
font-size: 0.95rem;
}
.status-ok { color: var(--success-color, #4a7c59); }
.status-error { color: var(--error-color, #c41e3a); }
/* Cached URLs */
.cached-section h3 {
font-size: 1rem;
color: var(--text-secondary, #666);
margin: 1.5rem 0 0.75rem 0;
}
.url-categories {
margin: 0;
}
.url-category {
margin-bottom: 0.5rem;
border: 1px solid var(--border-color, #ddd);
border-radius: 8px;
overflow: hidden;
}
.url-category-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.75rem 1rem;
background: var(--border-color, #ddd);
cursor: pointer;
user-select: none;
font-weight: 500;
font-size: 0.95rem;
}
.url-category-header:hover {
background: var(--text-secondary, #666);
color: var(--bg-color, #fffff8);
}
.url-category.selected .url-category-header {
outline: 2px solid var(--success-color, #4a7c59);
outline-offset: -2px;
}
.url-category-toggle {
font-size: 0.75rem;
transition: transform 0.2s;
}
.url-category.expanded .url-category-toggle {
transform: rotate(90deg);
}
.url-category-content {
display: none;
max-height: 300px;
overflow-y: auto;
padding: 0.5rem;
background: var(--bg-color, #fffff8);
}
.url-category.expanded .url-category-content {
display: block;
}
.url-item {
display: block;
padding: 0.4rem 0.75rem;
color: var(--link-color, #333);
text-decoration: none;
font-size: 0.85rem;
border-radius: 4px;
}
.url-item:hover {
background: var(--border-color, #ddd);
}
.url-item.selected {
background: var(--success-color, #4a7c59);
color: white;
}
.url-count {
font-size: 0.8rem;
color: var(--text-secondary, #666);
font-weight: normal;
}
</style>
{% endblock %}
{% block content %}
<div class="offline-hero">
<h1>Offline Mode <span class="badge" id="connection-badge">checking...</span></h1>
<p class="subtitle">Access the complete KJV Bible without internet</p>
</div>
<div class="status-card" id="status-card">
<div class="status-icon" id="status-icon"></div>
<div class="status-content">
<div class="status-title" id="status-title">Checking status...</div>
<div class="status-detail" id="status-detail">Please wait</div>
</div>
</div>
<div class="download-section">
<h2>Download for Offline</h2>
<p>Download all ~48,000 pages including chapters, verse commentary, interlinear text, stories, and study resources.</p>
<button class="download-btn" id="start-cache-btn">
<span>Download Everything</span>
</button>
<div class="download-progress" id="cache-progress">
<div class="progress-bar">
<div class="progress-fill" id="progress-fill"></div>
</div>
<div class="progress-text" id="progress-text">Preparing...</div>
</div>
</div>
<div class="quick-access">
<h2>Quick Access</h2>
<div class="quick-grid">
<a href="/books" class="quick-card">
<span class="quick-card-icon">📖</span>
<span class="quick-card-label">All Books</span>
</a>
<a href="/stories" class="quick-card">
<span class="quick-card-icon">📚</span>
<span class="quick-card-label">Stories</span>
</a>
<a href="/topics" class="quick-card">
<span class="quick-card-icon">🏷️</span>
<span class="quick-card-label">Topics</span>
</a>
<a href="/reading-plans" class="quick-card">
<span class="quick-card-icon">📅</span>
<span class="quick-card-label">Reading Plans</span>
</a>
<a href="/study-guides" class="quick-card">
<span class="quick-card-icon">📝</span>
<span class="quick-card-label">Study Guides</span>
</a>
<a href="/resources" class="quick-card">
<span class="quick-card-icon">📂</span>
<span class="quick-card-label">Resources</span>
</a>
</div>
</div>
<div class="reader-section">
<h2>Bible Reader</h2>
<p>Read directly from cached data (always works offline)</p>
<div class="reader-controls">
<select id="book-select">
<option value="">Select book...</option>
</select>
<select id="chapter-select" disabled>
<option value="">Chapter</option>
</select>
<div class="nav-buttons">
<button class="nav-btn" id="prev-btn" disabled>← Prev</button>
<button class="nav-btn" id="next-btn" disabled>Next →</button>
</div>
</div>
<div id="chapter-content"></div>
<p class="keyboard-hint">Keyboard: ←/→ navigate chapters • g = Genesis 1 • ? = help</p>
</div>
<details class="tech-details">
<summary>Technical Details</summary>
<div class="tech-grid">
<div class="tech-item">
<dt>Service Worker</dt>
<dd id="sw-status">Checking...</dd>
</div>
<div class="tech-item">
<dt>Cache Storage</dt>
<dd id="cache-status">Checking...</dd>
</div>
<div class="tech-item">
<dt>Bible Data</dt>
<dd id="bible-status">Checking...</dd>
</div>
<div class="tech-item">
<dt>Pages Cached</dt>
<dd id="pages-cached">Checking...</dd>
</div>
</div>
<div class="cached-section">
<h3>Cached Pages <span id="cached-count" style="color: var(--success-color);">0</span></h3>
<div id="cached-links" class="url-categories">
<p style="color: var(--text-secondary); font-size: 0.9rem;">Scanning cache...</p>
</div>
</div>
</details>
{% endblock %}
{% block extra_js %}
<script>
(function() {
const connectionBadge = document.getElementById('connection-badge');
const swStatus = document.getElementById('sw-status');
const cacheStatus = document.getElementById('cache-status');
const bibleStatus = document.getElementById('bible-status');
const pagesCached = document.getElementById('pages-cached');
const cachedLinks = document.getElementById('cached-links');
const cachedCount = document.getElementById('cached-count');
const statusCard = document.getElementById('status-card');
const statusIcon = document.getElementById('status-icon');
const statusTitle = document.getElementById('status-title');
const statusDetail = document.getElementById('status-detail');
function updateStatusCard(type, icon, title, detail) {
statusCard.className = 'status-card status-' + type;
statusIcon.textContent = icon;
statusTitle.textContent = title;
statusDetail.innerHTML = detail;
}
function updateConnectionStatus() {
if (navigator.onLine) {
connectionBadge.textContent = 'Online';
connectionBadge.className = 'badge badge-online';
} else {
connectionBadge.textContent = 'Offline';
connectionBadge.className = 'badge badge-offline';
}
}
updateConnectionStatus();
window.addEventListener('online', updateConnectionStatus);
window.addEventListener('offline', updateConnectionStatus);
async function checkServiceWorker() {
if ('serviceWorker' in navigator) {
try {
const registration = await navigator.serviceWorker.getRegistration();
if (registration && registration.active) {
swStatus.innerHTML = '<span class="status-ok">✓ Active</span>';
const cacheNames = await caches.keys();
let totalCached = 0;
for (const cacheName of cacheNames) {
const cache = await caches.open(cacheName);
const keys = await cache.keys();
totalCached += keys.filter(req => !new URL(req.url).pathname.startsWith('/static/')).length;
}
const TOTAL_SITE_PAGES = 48000;
const percentCached = Math.round((totalCached / TOTAL_SITE_PAGES) * 100);
if (totalCached > 45000) {
updateStatusCard('ok', '✅', 'Ready for offline use',
'<strong>' + totalCached.toLocaleString() + '</strong> pages cached. The entire site is available offline.');
} else if (totalCached > 0) {
updateStatusCard('warning', '📥', 'Partially cached (' + percentCached + '%)',
totalCached.toLocaleString() + ' of ' + TOTAL_SITE_PAGES.toLocaleString() + ' pages. Click download to continue.');
} else {
updateStatusCard('warning', '📥', 'Ready to download',
'Click the download button to cache all ' + TOTAL_SITE_PAGES.toLocaleString() + ' pages for offline access.');
}
} else {
swStatus.innerHTML = '<span class="status-error">✗ Not active</span>';
updateStatusCard('error', '⚠️', 'Service worker not active',
'Try refreshing the page. If using a content blocker, you may need to whitelist this site.');
}
} catch (err) {
swStatus.innerHTML = '<span class="status-error">✗ Error</span>';
updateStatusCard('error', '❌', 'Error', err.message);
}
} else {
swStatus.innerHTML = '<span class="status-error">✗ Not supported</span>';
updateStatusCard('error', '🌐', 'Not supported', 'Your browser does not support service workers.');
}
}
async function checkCaches() {
if ('caches' in window) {
try {
const cacheNames = await caches.keys();
cacheStatus.innerHTML = cacheNames.length > 0 ?
'<span class="status-ok">✓ ' + cacheNames.length + ' cache(s)</span>' :
'<span class="status-error">✗ Empty</span>';
const allUrls = [];
for (const cacheName of cacheNames) {
const cache = await caches.open(cacheName);
const keys = await cache.keys();
keys.forEach(req => {
const url = new URL(req.url);
if (url.pathname && !url.pathname.startsWith('/static/')) {
allUrls.push(url.pathname);
}
});
}
const uniqueUrls = [...new Set(allUrls)].sort();
pagesCached.innerHTML = '<span class="status-ok">' + uniqueUrls.length.toLocaleString() + '</span>';
cachedCount.textContent = uniqueUrls.length.toLocaleString();
const categories = {
'Main Pages': [], 'Bible Chapters': [], 'Verse Commentary': [],
'Interlinear': [], 'Stories': [], 'Topics': [],
'Study Guides': [], 'Reading Plans': [], 'Resources': [], 'Other': []
};
uniqueUrls.forEach(url => {
if (url === '/' || url === '/books' || url === '/offline' || url === '/search' || url === '/resources' || url === '/study-guides' || url === '/reading-plans' || url === '/topics' || url === '/stories') {
categories['Main Pages'].push(url);
} else if (url.match(/^\/book\/[^\/]+\/\d+$/)) {
categories['Bible Chapters'].push(url);
} else if (url.match(/^\/book\/[^\/]+\/\d+\/\d+/)) {
categories['Verse Commentary'].push(url);
} else if (url.startsWith('/interlinear/')) {
categories['Interlinear'].push(url);
} else if (url.startsWith('/book/')) {
categories['Bible Chapters'].push(url);
} else if (url.startsWith('/topics/')) {
categories['Topics'].push(url);
} else if (url.startsWith('/stories/')) {
categories['Stories'].push(url);
} else if (url.startsWith('/study-guides/')) {
categories['Study Guides'].push(url);
} else if (url.startsWith('/reading-plans/')) {
categories['Reading Plans'].push(url);
} else {
categories['Other'].push(url);
}
});
function formatLabel(url) {
if (url === '/') return 'Home';
let label = decodeURIComponent(url).replace(/^\//, '').replace(/-/g, ' ');
['book/', 'interlinear/', 'stories/', 'topics/', 'study-guides/', 'reading-plans/'].forEach(p => {
label = label.replace(p.replace('-', ' '), '');
});
return label;
}
let html = '';
for (const [category, urls] of Object.entries(categories)) {
if (urls.length > 0) {
html += '<div class="url-category">';
html += '<div class="url-category-header" onclick="this.parentNode.classList.toggle(\'expanded\')">';
html += '<span>' + category + ' <span class="url-count">(' + urls.length.toLocaleString() + ')</span></span>';
html += '<span class="url-category-toggle">▶</span>';
html += '</div>';
html += '<div class="url-category-content">';
urls.slice(0, 500).forEach(url => {
html += '<a href="' + url + '" class="url-item">' + formatLabel(url) + '</a>';
});
if (urls.length > 500) {
html += '<div style="padding: 0.5rem; color: var(--text-secondary); font-size: 0.85rem;">...and ' + (urls.length - 500).toLocaleString() + ' more</div>';
}
html += '</div></div>';
}
}
cachedLinks.innerHTML = html || '<p style="color: var(--text-secondary);">No pages cached yet.</p>';
} catch (err) {
cacheStatus.innerHTML = '<span class="status-error">✗ Error</span>';
}
}
}
async function checkBibleData() {
try {
let found = false;
const cacheNames = await caches.keys();
for (const cacheName of cacheNames) {
const cache = await caches.open(cacheName);
const response = await cache.match('/static/verses-1769.json');
if (response) {
const data = await response.json();
bibleStatus.innerHTML = '<span class="status-ok">✓ ' + Object.keys(data).length.toLocaleString() + ' verses</span>';
found = true;
break;
}
}
if (!found) {
bibleStatus.innerHTML = '<span class="status-error">✗ Not cached</span>';
}
} catch (err) {
bibleStatus.innerHTML = '<span class="status-error">✗ Error</span>';
}
}
checkServiceWorker();
checkCaches();
checkBibleData();
// Download controls
const startCacheBtn = document.getElementById('start-cache-btn');
const cacheProgress = document.getElementById('cache-progress');
const progressFill = document.getElementById('progress-fill');
const progressText = document.getElementById('progress-text');
startCacheBtn.addEventListener('click', async function() {
if (!('serviceWorker' in navigator)) return alert('Service workers not supported.');
const registration = await navigator.serviceWorker.getRegistration();
if (!registration || !registration.active) return alert('Service worker not active. Refresh and try again.');
startCacheBtn.disabled = true;
startCacheBtn.querySelector('span').textContent = 'Downloading...';
cacheProgress.classList.add('active');
progressText.textContent = 'Starting...';
registration.active.postMessage({ type: 'START_CACHING' });
});
if ('serviceWorker' in navigator) {
navigator.serviceWorker.addEventListener('message', function(event) {
const data = event.data;
if (data.type === 'CACHE_PROGRESS') {
cacheProgress.classList.add('active');
startCacheBtn.disabled = true;
const pct = Math.round((data.cached / data.total) * 100);
progressFill.style.width = pct + '%';
progressText.textContent = data.cached.toLocaleString() + ' / ' + data.total.toLocaleString() + ' (' + pct + '%)';
} else if (data.type === 'CACHE_COMPLETE') {
progressFill.style.width = '100%';
progressText.innerHTML = '<span class="cache-complete">✓ Complete! ' + data.total.toLocaleString() + ' pages cached.</span>';
startCacheBtn.querySelector('span').textContent = 'Refresh Cache';
startCacheBtn.disabled = false;
setTimeout(() => { checkCaches(); checkServiceWorker(); }, 500);
} else if (data.type === 'CACHE_ERROR') {
progressText.innerHTML = '<span class="status-error">Error: ' + data.error + '</span>';
startCacheBtn.disabled = false;
startCacheBtn.querySelector('span').textContent = 'Try Again';
}
});
navigator.serviceWorker.ready.then(function(registration) {
if (registration.active) registration.active.postMessage({ type: 'GET_CACHE_STATUS' });
});
}
// Bible Reader
let bibleData = null, bookStructure = {}, currentBook = '', currentChapter = 1;
const BOOKS = ['Genesis','Exodus','Leviticus','Numbers','Deuteronomy','Joshua','Judges','Ruth','1 Samuel','2 Samuel','1 Kings','2 Kings','1 Chronicles','2 Chronicles','Ezra','Nehemiah','Esther','Job','Psalms','Proverbs','Ecclesiastes','Song of Solomon','Isaiah','Jeremiah','Lamentations','Ezekiel','Daniel','Hosea','Joel','Amos','Obadiah','Jonah','Micah','Nahum','Habakkuk','Zephaniah','Haggai','Zechariah','Malachi','Matthew','Mark','Luke','John','Acts','Romans','1 Corinthians','2 Corinthians','Galatians','Ephesians','Philippians','Colossians','1 Thessalonians','2 Thessalonians','1 Timothy','2 Timothy','Titus','Philemon','Hebrews','James','1 Peter','2 Peter','1 John','2 John','3 John','Jude','Revelation'];
const bookSelect = document.getElementById('book-select');
const chapterSelect = document.getElementById('chapter-select');
const prevBtn = document.getElementById('prev-btn');
const nextBtn = document.getElementById('next-btn');
const content = document.getElementById('chapter-content');
async function loadBibleData() {
try {
let response = null;
const cacheNames = await caches.keys();
for (const cacheName of cacheNames) {
const cache = await caches.open(cacheName);
response = await cache.match('/static/verses-1769.json');
if (response) break;
}
if (!response) response = await fetch('/static/verses-1769.json');
if (!response || !response.ok) throw new Error('Could not load Bible data');
bibleData = await response.json();
bookStructure = {};
for (const ref in bibleData) {
const m = ref.match(/^(.+) (\d+):(\d+)$/);
if (m) {
const [, book, chapter, verse] = m;
if (!bookStructure[book]) bookStructure[book] = {};
if (!bookStructure[book][chapter]) bookStructure[book][chapter] = [];
bookStructure[book][chapter].push(parseInt(verse));
}
}
BOOKS.forEach(book => {
if (bookStructure[book]) {
const opt = document.createElement('option');
opt.value = book;
opt.textContent = book;
bookSelect.appendChild(opt);
}
});
const params = new URLSearchParams(window.location.search);
const book = params.get('book'), chapter = params.get('chapter');
if (book && bookStructure[book]) {
bookSelect.value = book;
populateChapters(book);
if (chapter && bookStructure[book][chapter]) {
chapterSelect.value = chapter;
renderChapter(book, chapter);
}
}
} catch (err) {
console.error('Failed to load Bible:', err);
}
}
function populateChapters(book) {
chapterSelect.innerHTML = '<option value="">Chapter</option>';
if (!bookStructure[book]) return;
Object.keys(bookStructure[book]).map(Number).sort((a,b) => a-b).forEach(ch => {
const opt = document.createElement('option');
opt.value = ch;
opt.textContent = ch;
chapterSelect.appendChild(opt);
});
chapterSelect.disabled = false;
}
function renderChapter(book, chapter) {
if (!bookStructure[book] || !bookStructure[book][chapter]) return;
currentBook = book;
currentChapter = parseInt(chapter);
content.classList.add('active');
const verses = bookStructure[book][chapter].sort((a,b) => a-b);
let html = '<h3>' + book + ' ' + chapter + '</h3>';
verses.forEach(v => {
const text = (bibleData[book + ' ' + chapter + ':' + v] || '').replace(/^#\s*/, '');
html += '<p><span class="verse-num">' + v + '</span>' + text + '</p>';
});
content.innerHTML = html;
const chapters = Object.keys(bookStructure[currentBook]).map(Number).sort((a,b) => a-b);
const bookIdx = BOOKS.indexOf(currentBook);
prevBtn.disabled = !(currentChapter > chapters[0] || bookIdx > 0);
nextBtn.disabled = !(currentChapter < chapters[chapters.length-1] || bookIdx < BOOKS.length-1);
history.replaceState(null, '', '/offline?book=' + encodeURIComponent(book) + '&chapter=' + chapter);
}
function navigate(dir) {
const chapters = Object.keys(bookStructure[currentBook]).map(Number).sort((a,b) => a-b);
const idx = chapters.indexOf(currentChapter);
if (dir === -1) {
if (idx > 0) { chapterSelect.value = chapters[idx-1]; renderChapter(currentBook, chapters[idx-1]); }
else {
const bi = BOOKS.indexOf(currentBook);
if (bi > 0) {
const pb = BOOKS[bi-1], pc = Object.keys(bookStructure[pb]).map(Number).sort((a,b) => a-b);
bookSelect.value = pb; populateChapters(pb); chapterSelect.value = pc[pc.length-1];
renderChapter(pb, pc[pc.length-1]);
}
}
} else {
if (idx < chapters.length-1) { chapterSelect.value = chapters[idx+1]; renderChapter(currentBook, chapters[idx+1]); }
else {
const bi = BOOKS.indexOf(currentBook);
if (bi < BOOKS.length-1) {
const nb = BOOKS[bi+1], nc = Object.keys(bookStructure[nb]).map(Number).sort((a,b) => a-b);
bookSelect.value = nb; populateChapters(nb); chapterSelect.value = nc[0];
renderChapter(nb, nc[0]);
}
}
}
}
bookSelect.addEventListener('change', function() {
if (this.value) {
populateChapters(this.value);
const ch = Object.keys(bookStructure[this.value]).map(Number).sort((a,b) => a-b)[0];
chapterSelect.value = ch;
renderChapter(this.value, ch);
}
});
chapterSelect.addEventListener('change', function() { if (this.value && currentBook) renderChapter(currentBook, this.value); });
prevBtn.addEventListener('click', () => navigate(-1));
nextBtn.addEventListener('click', () => navigate(1));
document.addEventListener('keydown', function(e) {
if (e.target.tagName === 'SELECT' || e.target.tagName === 'INPUT') return;
if (e.key === 'ArrowLeft' || e.key === 'h') { e.preventDefault(); if (!prevBtn.disabled) navigate(-1); }
else if (e.key === 'ArrowRight' || e.key === 'l') { e.preventDefault(); if (!nextBtn.disabled) navigate(1); }
else if (e.key === 'g') { e.preventDefault(); bookSelect.value = 'Genesis'; populateChapters('Genesis'); chapterSelect.value = '1'; renderChapter('Genesis', '1'); }
else if (e.key === '?') { e.preventDefault(); alert('Keyboard shortcuts:\n\n←/h - Previous chapter\n→/l - Next chapter\ng - Go to Genesis 1\n? - Show help'); }
});
loadBibleData();
})();
</script>
{% endblock %}