Files
kjvstudy.org/kjvstudy_org/templates/offline.html
T
kennethreitz 102115abd0 Convert offline page to extend base.html for sidebar
The offline page now uses Jinja2 template inheritance to get the
site sidebar automatically. All functionality preserved.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-30 01:59:01 -05:00

1274 lines
49 KiB
HTML

{% extends "base.html" %}
{% block title %}Offline Mode - KJV Study{% endblock %}
{% block extra_head %}
<style>
.badge {
display: inline-block;
padding: 0.25rem 0.5rem;
border-radius: 4px;
font-size: 0.8rem;
margin-left: 0.5rem;
vertical-align: middle;
}
.badge-offline { background: var(--error-color, #c41e3a); color: white; }
.badge-online { background: var(--success-color, #4a7c59); color: white; }
.debug-section {
background: var(--border-color, #ddd);
padding: 1rem;
border-radius: 4px;
margin: 1rem 0;
font-family: monospace;
font-size: 0.85rem;
}
.debug-section dt { font-weight: bold; margin-top: 0.5rem; }
.debug-section dd { margin-left: 1rem; color: var(--text-secondary, #666); }
.status-ok { color: var(--success-color, #4a7c59); }
.status-error { color: var(--error-color, #c41e3a); }
.nav-section {
display: flex;
gap: 1rem;
margin: 2rem 0;
flex-wrap: wrap;
align-items: center;
}
.nav-section select {
padding: 0.5rem;
font-size: 1rem;
border: 1px solid var(--border-color, #ddd);
border-radius: 4px;
background: var(--bg-color, #fffff8);
color: var(--text-color, #111);
font-family: inherit;
}
.chapter-nav {
display: flex;
gap: 0.5rem;
}
.nav-btn {
padding: 0.5rem 1rem;
border: 1px solid var(--border-color, #ddd);
border-radius: 4px;
background: var(--bg-color, #fffff8);
color: var(--text-color, #111);
cursor: pointer;
font-size: 1rem;
}
.nav-btn:hover { background: var(--border-color, #ddd); }
.nav-btn:disabled { opacity: 0.5; cursor: not-allowed; }
#chapter-content {
line-height: 2;
font-size: 1.2rem;
display: none;
}
#chapter-content.active {
display: block;
}
#chapter-content p {
margin: 1rem 0;
}
.verse-num {
font-size: 0.9rem;
vertical-align: super;
margin-right: 0.25rem;
color: var(--text-secondary, #666);
}
.loading {
text-align: center;
padding: 2rem;
color: var(--text-secondary, #666);
}
#keyboard-hint {
font-size: 0.85rem;
color: var(--text-secondary, #666);
margin-top: 1rem;
font-style: italic;
}
#cached-count {
font-size: 1.5rem;
font-weight: bold;
color: var(--success-color, #4a7c59);
}
.cache-controls {
background: linear-gradient(135deg, #f0f9ff 0%, #e0f2fe 100%);
padding: 1.5rem;
border-radius: 8px;
margin: 1.5rem 0;
border: 1px solid #bae6fd;
}
[data-theme="dark"] .cache-controls {
background: linear-gradient(135deg, #1e3a5f 0%, #1e293b 100%);
border-color: #334155;
}
.cache-controls h3 {
margin: 0 0 0.5rem 0;
color: var(--text-color, #111);
}
.cache-controls p {
margin: 0 0 1rem 0;
color: var(--text-secondary, #666);
font-size: 0.95rem;
}
.cache-btn {
padding: 0.75rem 1.5rem;
font-size: 1rem;
font-weight: 600;
border: none;
border-radius: 6px;
background: var(--success-color, #4a7c59);
color: white;
cursor: pointer;
transition: all 0.2s;
}
.cache-btn:hover {
filter: brightness(1.1);
transform: translateY(-1px);
}
.cache-btn:disabled {
opacity: 0.6;
cursor: not-allowed;
transform: none;
}
.cache-progress {
margin-top: 1rem;
display: none;
}
.cache-progress.active {
display: block;
}
.progress-bar {
width: 100%;
height: 8px;
background: var(--border-color, #ddd);
border-radius: 4px;
overflow: hidden;
margin-bottom: 0.5rem;
}
.progress-fill {
height: 100%;
background: var(--success-color, #4a7c59);
width: 0%;
transition: width 0.3s ease;
}
.progress-text {
font-size: 0.85rem;
color: var(--text-secondary, #666);
}
.cache-complete {
color: var(--success-color, #4a7c59);
font-weight: 600;
}
.status-card {
display: flex;
align-items: flex-start;
gap: 1rem;
padding: 1.25rem;
border-radius: 8px;
margin-bottom: 1.5rem;
border: 1px solid var(--border-color, #ddd);
background: var(--bg-color, #fffff8);
}
.status-card.status-ok {
border-color: var(--success-color, #4a7c59);
background: linear-gradient(135deg, #f0fdf4 0%, #dcfce7 100%);
}
.status-card.status-error {
border-color: var(--error-color, #c41e3a);
background: linear-gradient(135deg, #fef2f2 0%, #fee2e2 100%);
}
.status-card.status-warning {
border-color: #f59e0b;
background: linear-gradient(135deg, #fffbeb 0%, #fef3c7 100%);
}
[data-theme="dark"] .status-card.status-ok {
background: linear-gradient(135deg, #14532d 0%, #166534 100%);
}
[data-theme="dark"] .status-card.status-error {
background: linear-gradient(135deg, #7f1d1d 0%, #991b1b 100%);
}
[data-theme="dark"] .status-card.status-warning {
background: linear-gradient(135deg, #78350f 0%, #92400e 100%);
}
.status-icon {
font-size: 2rem;
line-height: 1;
}
.status-content {
flex: 1;
}
.status-title {
font-weight: 600;
font-size: 1.1rem;
margin-bottom: 0.25rem;
}
.status-detail {
color: var(--text-secondary, #666);
font-size: 0.9rem;
line-height: 1.5;
}
.status-detail code {
background: rgba(0,0,0,0.1);
padding: 0.1rem 0.3rem;
border-radius: 3px;
font-size: 0.85em;
}
[data-theme="dark"] .status-detail code {
background: rgba(255,255,255,0.1);
}
.quick-links {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
gap: 1rem;
margin: 1.5rem 0;
}
.quick-link {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 1rem;
background: var(--bg-color, #fffff8);
border: 1px solid var(--border-color, #ddd);
border-radius: 8px;
text-decoration: none;
color: var(--text-color, #111);
font-weight: 500;
transition: all 0.2s;
}
.quick-link:hover {
border-color: var(--success-color, #4a7c59);
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0,0,0,0.1);
}
.debug-details {
margin-top: 3rem;
padding-top: 2rem;
border-top: 1px solid var(--border-color, #ddd);
}
.debug-details summary {
cursor: pointer;
color: var(--text-secondary, #666);
font-size: 0.9rem;
padding: 0.5rem 0;
}
.debug-details summary:hover {
color: var(--text-color, #111);
}
.debug-details h3 {
margin-top: 1.5rem;
font-size: 1rem;
color: var(--text-secondary, #666);
}
/* Collapsible URL sections */
.url-categories {
margin: 1rem 0;
}
.url-category {
margin-bottom: 0.5rem;
border: 1px solid var(--border-color, #ddd);
border-radius: 6px;
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;
}
.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.8rem;
transition: transform 0.2s;
}
.url-category.expanded .url-category-toggle {
transform: rotate(90deg);
}
.url-category-content {
display: none;
max-height: 400px;
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.9rem;
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;
}
.url-nav-hint {
font-size: 0.8rem;
color: var(--text-secondary, #666);
margin-top: 0.5rem;
font-style: italic;
}
</style>
{% endblock %}
{% block content %}
<h1>Offline Mode <span class="badge" id="connection-badge">checking...</span></h1>
<p class="subtitle">KJV Study - Available without internet</p>
<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 service worker...</div>
<div class="status-detail" id="status-detail">Please wait</div>
</div>
</div>
<div class="cache-controls">
<h3>Enable Offline Access</h3>
<p>Download the entire site for offline use: all Bible chapters, individual verse pages with commentary, stories, study guides, and resources. The sitemap will be parsed to find all available pages.</p>
<button class="cache-btn" id="start-cache-btn">Download for Offline Use</button>
<div class="cache-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>
<h2>Quick Access</h2>
<p>Jump directly to commonly used sections:</p>
<div class="quick-links">
<a href="/books" class="quick-link">📖 All Books</a>
<a href="/stories" class="quick-link">📚 Bible Stories</a>
<a href="/topics" class="quick-link">🏷️ Topics</a>
<a href="/reading-plans" class="quick-link">📅 Reading Plans</a>
<a href="/study-guides" class="quick-link">📝 Study Guides</a>
<a href="/resources" class="quick-link">📂 Resources</a>
</div>
<h2>Bible Reader</h2>
<p>Read any chapter using cached Bible data (works even without any downloads):</p>
<div class="nav-section">
<select id="book-select">
<option value="">Select a book...</option>
</select>
<select id="chapter-select" disabled>
<option value="">Chapter</option>
</select>
<div class="chapter-nav">
<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">
<p class="loading">Select a book and chapter above...</p>
</div>
<p id="keyboard-hint">Tip: j/k to select • ←/→ chapters • Enter to open • g=Genesis • ? for help</p>
<details class="debug-details">
<summary>Technical Details</summary>
<div class="debug-section">
<dl>
<dt>Service Worker</dt>
<dd id="sw-status">Checking...</dd>
<dt>Cache Storage</dt>
<dd id="cache-status">Checking...</dd>
<dt>Bible Data</dt>
<dd id="bible-status">Checking...</dd>
<dt>Pages Cached</dt>
<dd id="pages-cached">Checking...</dd>
</dl>
</div>
<h3>Cached Pages <span id="cached-count">0</span></h3>
<p class="url-nav-hint">Use j/k to navigate categories, Enter/Space to expand, then j/k to browse URLs</p>
<div id="cached-links" class="url-categories">
<p class="loading">Scanning cache...</p>
</div>
</details>
{% endblock %}
{% block extra_js %}
<script>
(function() {
// Debug elements
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');
// Status card elements
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;
}
// Update connection status
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);
// Check service worker status
async function checkServiceWorker() {
if ('serviceWorker' in navigator) {
try {
const registration = await navigator.serviceWorker.getRegistration();
if (registration && registration.active) {
const state = registration.active ? 'Active' :
registration.waiting ? 'Waiting' :
registration.installing ? 'Installing' : 'Unknown';
swStatus.innerHTML = '<span class="status-ok">✓ Registered (' + state + ')</span>';
swStatus.innerHTML += '<br>Scope: ' + registration.scope;
// Check how many pages are cached
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;
}
// Total pages in sitemap: ~48,000 (17k main + 31k verses)
const TOTAL_SITE_PAGES = 48000;
const percentCached = Math.round((totalCached / TOTAL_SITE_PAGES) * 100);
if (totalCached > 45000) {
updateStatusCard('ok', '✅', 'Ready for offline use',
totalCached.toLocaleString() + ' pages cached (' + percentCached + '%). The entire site is available offline.');
} else if (totalCached > 0) {
updateStatusCard('warning', '⚠️', 'Partially cached (' + percentCached + '%)',
totalCached.toLocaleString() + ' of ~' + TOTAL_SITE_PAGES.toLocaleString() + ' pages cached. Click "Download for Offline Use" to finish downloading.');
} else {
updateStatusCard('warning', '📥', 'Service worker ready',
'Click "Download for Offline Use" below to cache all ~' + TOTAL_SITE_PAGES.toLocaleString() + ' pages for offline access.');
}
} else if (registration) {
swStatus.innerHTML = '<span class="status-ok">✓ Registered (Installing)</span>';
updateStatusCard('warning', '⏳', 'Service worker installing...',
'Please wait a moment and refresh the page.');
} else {
swStatus.innerHTML = '<span class="status-error">✗ Not registered</span>';
swStatus.innerHTML += '<br>Try refreshing the page or check if content blockers are blocking it.';
updateStatusCard('error', '🚫', 'Service worker not registered',
'This could be caused by a content blocker or privacy extension. ' +
'Try:<br>• Disabling ad blockers for this site<br>' +
'• Checking browser privacy settings<br>' +
'• Using a different browser<br>' +
'• Making sure you\'re on <code>https://</code> or <code>localhost</code>');
}
} catch (err) {
swStatus.innerHTML = '<span class="status-error">✗ Error: ' + err.message + '</span>';
updateStatusCard('error', '❌', 'Error checking service worker',
'Error: ' + err.message + '<br>Try refreshing the page.');
}
} else {
swStatus.innerHTML = '<span class="status-error">✗ Not supported in this browser</span>';
updateStatusCard('error', '🌐', 'Service workers not supported',
'Your browser doesn\'t support service workers. ' +
'Try using a modern browser like Chrome, Firefox, Safari, or Edge.');
}
}
// Check cache status and list cached pages
async function checkCaches() {
if ('caches' in window) {
try {
const cacheNames = await caches.keys();
if (cacheNames.length > 0) {
cacheStatus.innerHTML = '<span class="status-ok">✓ Available</span>';
cacheStatus.innerHTML += '<br>Caches: ' + cacheNames.join(', ');
} else {
cacheStatus.innerHTML = '<span class="status-error">✗ No caches found</span>';
}
// List all cached URLs
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);
}
});
}
// Remove duplicates and sort
const uniqueUrls = [...new Set(allUrls)].sort();
pagesCached.innerHTML = '<span class="status-ok">' + uniqueUrls.length + ' pages</span>';
cachedCount.textContent = uniqueUrls.length;
// Group by category with better organization
const categories = {
'Main Pages': [],
'Bible Chapters': [],
'Verse Commentary': [],
'Interlinear': [],
'Stories': [],
'Topics': [],
'Study Guides': [],
'Reading Plans': [],
'Parables': [],
'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 if (url.startsWith('/parables/')) {
categories['Parables'].push(url);
} else if (url.match(/^\/(christology|names-of-god|biblical-|fruits-|messianic-|miracles-|prayers-|spirits-|types-|women-|ten-|the-twelve|trinity|worship|grace|eschatology|ecclesiology|soteriology|pneumatology|hamartiology|anthropology|bibliology|theology-proper|justification|sanctification|providence|kingdom-of-god|law-and-gospel|armor-of-god|beatitudes|i-am-statements|names-of-christ|personifications|tetragrammaton|blood-in-scripture)/)) {
categories['Resources'].push(url);
} else {
categories['Other'].push(url);
}
});
// Helper to format URL for display (decode %20, clean up path)
function formatUrlLabel(url) {
if (url === '/') return 'Home';
// Decode URL encoding (%20 -> space, etc)
let label = decodeURIComponent(url);
// Remove leading slash
label = label.replace(/^\//, '');
// Replace dashes with spaces
label = label.replace(/-/g, ' ');
// For verse URLs, make them prettier
label = label.replace(/^book\//, '');
label = label.replace(/^interlinear\//, '');
label = label.replace(/^stories\//, '');
label = label.replace(/^topics\//, '');
label = label.replace(/^study guides\//, '');
label = label.replace(/^reading plans\//, '');
label = label.replace(/^parables\//, '');
return label;
}
// Render collapsible categories
let html = '';
let categoryIndex = 0;
for (const [category, urls] of Object.entries(categories)) {
if (urls.length > 0) {
html += '<div class="url-category" data-category-index="' + categoryIndex + '">';
html += '<div class="url-category-header" onclick="toggleCategory(this.parentNode)">';
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.forEach((url, i) => {
const label = formatUrlLabel(url);
html += '<a href="' + url + '" class="url-item" data-url-index="' + i + '">' + label + '</a>';
});
html += '</div>';
html += '</div>';
categoryIndex++;
}
}
cachedLinks.innerHTML = html || '<p>No pages cached yet. Browse the site while online to cache pages.</p>';
// Initialize URL navigation
initUrlNavigation();
} catch (err) {
cacheStatus.innerHTML = '<span class="status-error">✗ Error: ' + err.message + '</span>';
cachedLinks.innerHTML = '<p class="status-error">Could not read cache.</p>';
}
} else {
cacheStatus.innerHTML = '<span class="status-error">✗ Not supported</span>';
}
}
// Check Bible data
async function checkBibleData() {
try {
// Try cache first
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();
const verseCount = Object.keys(data).length;
bibleStatus.innerHTML = '<span class="status-ok">✓ Cached (' + verseCount.toLocaleString() + ' verses)</span>';
found = true;
break;
}
}
if (!found) {
bibleStatus.innerHTML = '<span class="status-error">✗ Not cached</span>';
bibleStatus.innerHTML += '<br>Visit the site while online to cache Bible data.';
}
} catch (err) {
bibleStatus.innerHTML = '<span class="status-error">✗ Error: ' + err.message + '</span>';
}
}
// Run all checks
checkServiceWorker();
checkCaches();
checkBibleData();
// Cache 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)) {
alert('Service workers are not supported in this browser.');
return;
}
const registration = await navigator.serviceWorker.getRegistration();
if (!registration || !registration.active) {
alert('Service worker is not active. Please refresh the page and try again.');
return;
}
// Disable button and show progress
startCacheBtn.disabled = true;
startCacheBtn.textContent = 'Downloading...';
cacheProgress.classList.add('active');
progressText.textContent = 'Starting download...';
// Send message to service worker to start caching
registration.active.postMessage({ type: 'START_CACHING' });
});
// Listen for progress messages from service worker
if ('serviceWorker' in navigator) {
navigator.serviceWorker.addEventListener('message', function(event) {
const data = event.data;
if (data.type === 'CACHE_STATUS') {
cacheProgress.classList.add('active');
progressText.textContent = data.status;
} else if (data.type === 'CACHE_PROGRESS') {
// Show progress bar if caching is in progress
cacheProgress.classList.add('active');
startCacheBtn.disabled = true;
startCacheBtn.textContent = 'Downloading...';
const pct = Math.round((data.cached / data.total) * 100);
progressFill.style.width = pct + '%';
progressText.textContent = 'Downloading: ' + data.cached.toLocaleString() + ' / ' + data.total.toLocaleString() + ' pages (' + pct + '%)';
} else if (data.type === 'CACHE_COMPLETE') {
cacheProgress.classList.add('active');
progressFill.style.width = '100%';
progressText.innerHTML = '<span class="cache-complete">✓ Download complete! ' + data.total.toLocaleString() + ' pages available offline.</span>';
startCacheBtn.textContent = 'Refresh Cache';
startCacheBtn.disabled = false;
// Refresh the cache list
setTimeout(checkCaches, 500);
} else if (data.type === 'CACHE_ERROR') {
cacheProgress.classList.add('active');
progressText.innerHTML = '<span class="status-error">Error: ' + data.error + '</span>';
startCacheBtn.disabled = false;
startCacheBtn.textContent = 'Try Again';
}
});
// Check if caching is already in progress when page loads
navigator.serviceWorker.ready.then(function(registration) {
if (registration.active) {
registration.active.postMessage({ type: 'GET_CACHE_STATUS' });
}
});
}
// Bible Reader functionality
let bibleData = null;
let bookStructure = {};
let currentBook = '';
let 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();
buildBookStructure();
populateBookSelect();
parseUrlAndNavigate();
} catch (err) {
console.error('Failed to load Bible:', err);
}
}
function buildBookStructure() {
bookStructure = {};
for (const ref in bibleData) {
const match = ref.match(/^(.+) (\d+):(\d+)$/);
if (match) {
const [, book, chapter, verse] = match;
if (!bookStructure[book]) bookStructure[book] = {};
if (!bookStructure[book][chapter]) bookStructure[book][chapter] = [];
bookStructure[book][chapter].push(parseInt(verse));
}
}
for (const book in bookStructure) {
for (const chapter in bookStructure[book]) {
bookStructure[book][chapter].sort((a, b) => a - b);
}
}
}
function populateBookSelect() {
BOOKS.forEach(book => {
if (bookStructure[book]) {
const opt = document.createElement('option');
opt.value = book;
opt.textContent = book;
bookSelect.appendChild(opt);
}
});
}
function populateChapterSelect(book) {
chapterSelect.innerHTML = '<option value="">Chapter</option>';
if (!bookStructure[book]) return;
const chapters = Object.keys(bookStructure[book]).map(Number).sort((a, b) => a - b);
chapters.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];
let html = '<h3>' + book + ' ' + chapter + '</h3>';
verses.forEach(verseNum => {
const ref = book + ' ' + chapter + ':' + verseNum;
const text = bibleData[ref] || '';
const cleanText = text.replace(/^#\s*/, '');
html += '<p><span class="verse-num">' + verseNum + '</span> ' + cleanText + '</p>';
});
content.innerHTML = html;
updateNavButtons();
history.replaceState(null, '', '/offline?book=' + encodeURIComponent(book) + '&chapter=' + chapter);
}
function updateNavButtons() {
const chapters = Object.keys(bookStructure[currentBook] || {}).map(Number).sort((a, b) => a - b);
const bookIndex = BOOKS.indexOf(currentBook);
prevBtn.disabled = !(currentChapter > chapters[0] || bookIndex > 0);
nextBtn.disabled = !(currentChapter < chapters[chapters.length - 1] || bookIndex < BOOKS.length - 1);
}
function goPrev() {
const chapters = Object.keys(bookStructure[currentBook] || {}).map(Number).sort((a, b) => a - b);
const chapterIndex = chapters.indexOf(currentChapter);
if (chapterIndex > 0) {
chapterSelect.value = chapters[chapterIndex - 1];
renderChapter(currentBook, chapters[chapterIndex - 1]);
} else {
const bookIndex = BOOKS.indexOf(currentBook);
if (bookIndex > 0) {
const prevBook = BOOKS[bookIndex - 1];
const prevChapters = Object.keys(bookStructure[prevBook] || {}).map(Number).sort((a, b) => a - b);
bookSelect.value = prevBook;
populateChapterSelect(prevBook);
chapterSelect.value = prevChapters[prevChapters.length - 1];
renderChapter(prevBook, prevChapters[prevChapters.length - 1]);
}
}
}
function goNext() {
const chapters = Object.keys(bookStructure[currentBook] || {}).map(Number).sort((a, b) => a - b);
const chapterIndex = chapters.indexOf(currentChapter);
if (chapterIndex < chapters.length - 1) {
chapterSelect.value = chapters[chapterIndex + 1];
renderChapter(currentBook, chapters[chapterIndex + 1]);
} else {
const bookIndex = BOOKS.indexOf(currentBook);
if (bookIndex < BOOKS.length - 1) {
const nextBook = BOOKS[bookIndex + 1];
const nextChapters = Object.keys(bookStructure[nextBook] || {}).map(Number).sort((a, b) => a - b);
bookSelect.value = nextBook;
populateChapterSelect(nextBook);
chapterSelect.value = nextChapters[0];
renderChapter(nextBook, nextChapters[0]);
}
}
}
function parseUrlAndNavigate() {
const params = new URLSearchParams(window.location.search);
const book = params.get('book');
const chapter = params.get('chapter');
if (book && bookStructure[book]) {
bookSelect.value = book;
populateChapterSelect(book);
if (chapter && bookStructure[book][chapter]) {
chapterSelect.value = chapter;
renderChapter(book, chapter);
}
}
}
bookSelect.addEventListener('change', function() {
const book = this.value;
if (book) {
populateChapterSelect(book);
const chapters = Object.keys(bookStructure[book]).map(Number).sort((a, b) => a - b);
if (chapters.length > 0) {
chapterSelect.value = chapters[0];
renderChapter(book, chapters[0]);
}
}
});
chapterSelect.addEventListener('change', function() {
if (this.value && currentBook) {
renderChapter(currentBook, this.value);
}
});
prevBtn.addEventListener('click', goPrev);
nextBtn.addEventListener('click', goNext);
// Keyboard navigation
const quickLinks = Array.from(document.querySelectorAll('.quick-link'));
let selectedQuickLink = -1;
let selectedVerse = -1;
let inQuickLinksZone = true;
function getVerseElements() {
return Array.from(content.querySelectorAll('p'));
}
function clearSelection() {
quickLinks.forEach(el => {
el.style.outline = '';
el.style.outlineOffset = '';
});
getVerseElements().forEach(el => {
el.style.outline = '';
el.style.outlineOffset = '';
});
}
function selectQuickLink(index) {
clearSelection();
inQuickLinksZone = true;
selectedVerse = -1;
if (index < 0) index = 0;
if (index >= quickLinks.length) index = quickLinks.length - 1;
selectedQuickLink = index;
quickLinks[index].style.outline = '2px solid #4a7c59';
quickLinks[index].style.outlineOffset = '4px';
quickLinks[index].scrollIntoView({ behavior: 'smooth', block: 'center' });
}
function selectVerse(index) {
clearSelection();
inQuickLinksZone = false;
selectedQuickLink = -1;
const verses = getVerseElements();
if (verses.length === 0) return;
if (index < 0) index = 0;
if (index >= verses.length) index = verses.length - 1;
selectedVerse = index;
verses[index].style.outline = '2px solid #4a7c59';
verses[index].style.outlineOffset = '4px';
verses[index].scrollIntoView({ behavior: 'smooth', block: 'center' });
}
document.addEventListener('keydown', function(e) {
if (e.target.tagName === 'SELECT' || e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') return;
const verses = getVerseElements();
if (e.key === 'ArrowDown' || e.key === 'j') {
e.preventDefault();
if (inQuickLinksZone) {
if (selectedQuickLink < 0) {
selectQuickLink(0);
} else if (selectedQuickLink + 3 < quickLinks.length) {
selectQuickLink(selectedQuickLink + 3);
} else if (verses.length > 0) {
selectVerse(0);
}
} else {
selectVerse(selectedVerse + 1);
}
} else if (e.key === 'ArrowUp' || e.key === 'k') {
e.preventDefault();
if (inQuickLinksZone) {
if (selectedQuickLink >= 3) {
selectQuickLink(selectedQuickLink - 3);
} else if (selectedQuickLink > 0) {
selectQuickLink(0);
}
} else {
if (selectedVerse <= 0) {
selectQuickLink(quickLinks.length - 1);
} else {
selectVerse(selectedVerse - 1);
}
}
} else if (e.key === 'ArrowLeft' || e.key === 'h') {
e.preventDefault();
if (inQuickLinksZone && selectedQuickLink > 0) {
selectQuickLink(selectedQuickLink - 1);
} else if (!inQuickLinksZone || selectedQuickLink < 0) {
if (!prevBtn.disabled) goPrev();
}
} else if (e.key === 'ArrowRight' || e.key === 'l') {
e.preventDefault();
if (inQuickLinksZone && selectedQuickLink >= 0 && selectedQuickLink < quickLinks.length - 1) {
selectQuickLink(selectedQuickLink + 1);
} else if (!inQuickLinksZone || selectedQuickLink < 0) {
if (!nextBtn.disabled) goNext();
}
} else if (e.key === 'Enter') {
e.preventDefault();
if (inQuickLinksZone && selectedQuickLink >= 0) {
window.location.href = quickLinks[selectedQuickLink].href;
}
} else if (e.key === 'Escape') {
e.preventDefault();
clearSelection();
selectedQuickLink = -1;
selectedVerse = -1;
inQuickLinksZone = true;
} else if (e.key === 'g') {
e.preventDefault();
if (bookStructure['Genesis']) {
bookSelect.value = 'Genesis';
populateChapterSelect('Genesis');
chapterSelect.value = '1';
renderChapter('Genesis', '1');
}
} else if (e.key === '?') {
e.preventDefault();
alert('Keyboard shortcuts:\n\n' +
'j/↓ - Move down\n' +
'k/↑ - Move up\n' +
'h/← - Previous chapter / Move left\n' +
'l/→ - Next chapter / Move right\n' +
'Enter - Open selected link\n' +
'g - Go to Genesis 1\n' +
'Esc - Clear selection\n' +
'? - Show this help');
}
});
loadBibleData();
// URL list navigation state
let urlNavMode = false;
let selectedCategoryIdx = -1;
let selectedUrlIdx = -1;
// Toggle category expand/collapse
window.toggleCategory = function(categoryEl) {
categoryEl.classList.toggle('expanded');
};
// Initialize URL navigation
window.initUrlNavigation = function() {
const categories = document.querySelectorAll('.url-category');
if (categories.length === 0) return;
};
function getUrlCategories() {
return Array.from(document.querySelectorAll('.url-category'));
}
function getUrlsInCategory(categoryEl) {
return Array.from(categoryEl.querySelectorAll('.url-item'));
}
function clearUrlSelection() {
document.querySelectorAll('.url-category.selected').forEach(el => el.classList.remove('selected'));
document.querySelectorAll('.url-item.selected').forEach(el => el.classList.remove('selected'));
}
function selectCategory(index) {
const categories = getUrlCategories();
if (categories.length === 0) return;
clearUrlSelection();
selectedUrlIdx = -1;
if (index < 0) index = 0;
if (index >= categories.length) index = categories.length - 1;
selectedCategoryIdx = index;
categories[index].classList.add('selected');
categories[index].scrollIntoView({ behavior: 'smooth', block: 'nearest' });
}
function selectUrlItem(categoryIndex, urlIndex) {
const categories = getUrlCategories();
if (categoryIndex < 0 || categoryIndex >= categories.length) return;
clearUrlSelection();
selectedCategoryIdx = categoryIndex;
const urls = getUrlsInCategory(categories[categoryIndex]);
if (urls.length === 0) return;
if (urlIndex < 0) urlIndex = 0;
if (urlIndex >= urls.length) urlIndex = urls.length - 1;
selectedUrlIdx = urlIndex;
categories[categoryIndex].classList.add('selected');
urls[urlIndex].classList.add('selected');
urls[urlIndex].scrollIntoView({ behavior: 'smooth', block: 'nearest' });
}
function expandCategory(index) {
const categories = getUrlCategories();
if (index >= 0 && index < categories.length) {
categories[index].classList.add('expanded');
}
}
function collapseCategory(index) {
const categories = getUrlCategories();
if (index >= 0 && index < categories.length) {
categories[index].classList.remove('expanded');
}
}
function isCategoryExpanded(index) {
const categories = getUrlCategories();
if (index >= 0 && index < categories.length) {
return categories[index].classList.contains('expanded');
}
return false;
}
// Handle URL list keyboard navigation when in Technical Details
document.addEventListener('keydown', function(e) {
const details = document.querySelector('.debug-details');
if (!details || !details.open) return;
if (e.target.tagName === 'SELECT' || e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') return;
const categories = getUrlCategories();
if (categories.length === 0) return;
const cachedLinksSection = document.getElementById('cached-links');
const rect = cachedLinksSection.getBoundingClientRect();
const isVisible = rect.top < window.innerHeight && rect.bottom > 0;
if (!isVisible && selectedCategoryIdx < 0) return;
if (e.key === 'u') {
e.preventDefault();
urlNavMode = true;
if (selectedCategoryIdx < 0) {
selectCategory(0);
}
return;
}
if (!urlNavMode && selectedCategoryIdx < 0) return;
if (e.key === 'j' || e.key === 'ArrowDown') {
if (urlNavMode || selectedCategoryIdx >= 0) {
e.preventDefault();
e.stopPropagation();
if (selectedUrlIdx >= 0 && isCategoryExpanded(selectedCategoryIdx)) {
const urls = getUrlsInCategory(categories[selectedCategoryIdx]);
if (selectedUrlIdx < urls.length - 1) {
selectUrlItem(selectedCategoryIdx, selectedUrlIdx + 1);
} else {
if (selectedCategoryIdx < categories.length - 1) {
collapseCategory(selectedCategoryIdx);
selectCategory(selectedCategoryIdx + 1);
}
}
} else if (selectedCategoryIdx >= 0) {
if (selectedCategoryIdx < categories.length - 1) {
selectCategory(selectedCategoryIdx + 1);
}
} else {
selectCategory(0);
}
}
} else if (e.key === 'k' || e.key === 'ArrowUp') {
if (urlNavMode || selectedCategoryIdx >= 0) {
e.preventDefault();
e.stopPropagation();
if (selectedUrlIdx >= 0 && isCategoryExpanded(selectedCategoryIdx)) {
if (selectedUrlIdx > 0) {
selectUrlItem(selectedCategoryIdx, selectedUrlIdx - 1);
} else {
selectedUrlIdx = -1;
selectCategory(selectedCategoryIdx);
}
} else if (selectedCategoryIdx > 0) {
selectCategory(selectedCategoryIdx - 1);
}
}
} else if (e.key === 'Enter' || e.key === ' ') {
if (selectedUrlIdx >= 0) {
const urls = getUrlsInCategory(categories[selectedCategoryIdx]);
if (urls[selectedUrlIdx]) {
window.location.href = urls[selectedUrlIdx].href;
}
} else if (selectedCategoryIdx >= 0) {
e.preventDefault();
e.stopPropagation();
if (isCategoryExpanded(selectedCategoryIdx)) {
collapseCategory(selectedCategoryIdx);
} else {
expandCategory(selectedCategoryIdx);
const urls = getUrlsInCategory(categories[selectedCategoryIdx]);
if (urls.length > 0) {
selectUrlItem(selectedCategoryIdx, 0);
}
}
}
} else if (e.key === 'Escape') {
if (selectedUrlIdx >= 0) {
e.preventDefault();
selectedUrlIdx = -1;
selectCategory(selectedCategoryIdx);
} else if (selectedCategoryIdx >= 0) {
e.preventDefault();
clearUrlSelection();
selectedCategoryIdx = -1;
urlNavMode = false;
}
} else if (e.key === 'l' || e.key === 'ArrowRight') {
if (selectedCategoryIdx >= 0 && !isCategoryExpanded(selectedCategoryIdx)) {
e.preventDefault();
e.stopPropagation();
expandCategory(selectedCategoryIdx);
const urls = getUrlsInCategory(categories[selectedCategoryIdx]);
if (urls.length > 0) {
selectUrlItem(selectedCategoryIdx, 0);
}
}
} else if (e.key === 'h' || e.key === 'ArrowLeft') {
if (selectedCategoryIdx >= 0) {
e.preventDefault();
e.stopPropagation();
if (selectedUrlIdx >= 0) {
selectedUrlIdx = -1;
selectCategory(selectedCategoryIdx);
} else if (isCategoryExpanded(selectedCategoryIdx)) {
collapseCategory(selectedCategoryIdx);
}
}
}
}, true);
})();
</script>
{% endblock %}