diff --git a/CLAUDE.md b/CLAUDE.md
index 167e790..17c4285 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -244,7 +244,7 @@ docker compose exec web uv run pytest tests/ -v # Run tests in container
- Use `uv` for all Python package management
- API responses use `name` field for books, not `book`
- API responses use `start`/`end` for verse ranges, not `start_verse`/`end_verse`
-- Error handling currently returns 500 for invalid inputs (should be 404, but tests accept both)
+- Error handling returns proper 404 for invalid inputs (not found resources)
- Book abbreviations are comprehensive but some edge cases may not work
- The project uses Tufte CSS for clean, readable typography
- Interlinear data is available for most verses but not all
diff --git a/kjvstudy_org/static/base.js b/kjvstudy_org/static/base.js
new file mode 100644
index 0000000..66fa27a
--- /dev/null
+++ b/kjvstudy_org/static/base.js
@@ -0,0 +1,1256 @@
+// Dark mode functionality
+(function() {
+ // Check for saved theme preference or default to light mode
+ const currentTheme = localStorage.getItem('theme') || 'light';
+ document.documentElement.setAttribute('data-theme', currentTheme);
+})();
+
+function toggleDarkMode() {
+ const currentTheme = document.documentElement.getAttribute('data-theme');
+ const newTheme = currentTheme === 'dark' ? 'light' : 'dark';
+ document.documentElement.setAttribute('data-theme', newTheme);
+ localStorage.setItem('theme', newTheme);
+}
+
+// Red letter toggle functionality
+(function() {
+ // Check for saved red letter preference or default to enabled
+ const redLettersEnabled = localStorage.getItem('redLetters') !== 'disabled';
+ if (!redLettersEnabled) {
+ document.documentElement.setAttribute('data-red-letters', 'disabled');
+ }
+})();
+
+function toggleRedLetters() {
+ const currentState = document.documentElement.getAttribute('data-red-letters');
+ const newState = currentState === 'disabled' ? 'enabled' : 'disabled';
+
+ if (newState === 'disabled') {
+ document.documentElement.setAttribute('data-red-letters', 'disabled');
+ localStorage.setItem('redLetters', 'disabled');
+ } else {
+ document.documentElement.removeAttribute('data-red-letters');
+ localStorage.removeItem('redLetters');
+ }
+}
+
+// Sidebar collapse state persistence
+(function() {
+ var toggle = document.getElementById('sidebar-toggle');
+ var savedState = localStorage.getItem('sidebarExpanded');
+ var isMobile = window.innerWidth <= 768;
+
+ // If user has explicitly set a preference, respect that
+ if (savedState === 'false') {
+ toggle.checked = false;
+ } else if (savedState === 'true') {
+ toggle.checked = true;
+ } else {
+ // No saved state - default to collapsed on mobile, expanded on desktop
+ toggle.checked = !isMobile;
+ }
+
+ toggle.addEventListener('change', function() {
+ localStorage.setItem('sidebarExpanded', toggle.checked ? 'true' : 'false');
+ });
+})();
+
+// Details elements (subsections) collapse state persistence
+(function() {
+ // Get all details elements in sidebar
+ var detailsElements = document.querySelectorAll('.nav-sidebar details');
+
+ // Restore saved states
+ detailsElements.forEach(function(details) {
+ var id = details.id || details.querySelector('summary')?.textContent.trim();
+ if (id) {
+ var savedState = localStorage.getItem('sidebar-details-' + id);
+ if (savedState === 'open') {
+ details.open = true;
+ } else if (savedState === 'closed') {
+ details.open = false;
+ }
+ }
+ });
+
+ // Save state on toggle
+ detailsElements.forEach(function(details) {
+ details.addEventListener('toggle', function() {
+ var id = this.id || this.querySelector('summary')?.textContent.trim();
+ if (id) {
+ localStorage.setItem('sidebar-details-' + id, this.open ? 'open' : 'closed');
+ }
+ });
+ });
+})();
+
+// Universal search functionality with smart verse navigation
+(function() {
+ var searchInput = document.getElementById('sidebar-search-input');
+ var dropdown = document.getElementById('search-results-dropdown');
+ if (!searchInput || !dropdown) return;
+
+ var searchTimeout = null;
+ var selectedIndex = -1;
+ var currentResults = [];
+
+ // Book name mapping (same as homepage)
+ var bookMap = {
+ 'genesis': 'Genesis', 'exodus': 'Exodus', 'leviticus': 'Leviticus', 'numbers': 'Numbers',
+ 'deuteronomy': 'Deuteronomy', 'joshua': 'Joshua', 'judges': 'Judges', 'ruth': 'Ruth',
+ '1 samuel': '1 Samuel', '2 samuel': '2 Samuel', '1 kings': '1 Kings', '2 kings': '2 Kings',
+ '1 chronicles': '1 Chronicles', '2 chronicles': '2 Chronicles', 'ezra': 'Ezra', 'nehemiah': 'Nehemiah',
+ 'esther': 'Esther', 'job': 'Job', 'psalms': 'Psalms', 'psalm': 'Psalms', 'proverbs': 'Proverbs',
+ 'ecclesiastes': 'Ecclesiastes', 'song of solomon': 'Song of Solomon', 'isaiah': 'Isaiah',
+ 'jeremiah': 'Jeremiah', 'lamentations': 'Lamentations', 'ezekiel': 'Ezekiel', 'daniel': 'Daniel',
+ 'hosea': 'Hosea', 'joel': 'Joel', 'amos': 'Amos', 'obadiah': 'Obadiah', 'jonah': 'Jonah',
+ 'micah': 'Micah', 'nahum': 'Nahum', 'habakkuk': 'Habakkuk', 'zephaniah': 'Zephaniah',
+ 'haggai': 'Haggai', 'zechariah': 'Zechariah', 'malachi': 'Malachi', 'matthew': 'Matthew',
+ 'mark': 'Mark', 'luke': 'Luke', 'john': 'John', 'acts': 'Acts', 'romans': 'Romans',
+ '1 corinthians': '1 Corinthians', '2 corinthians': '2 Corinthians', 'galatians': 'Galatians',
+ 'ephesians': 'Ephesians', 'philippians': 'Philippians', 'colossians': 'Colossians',
+ '1 thessalonians': '1 Thessalonians', '2 thessalonians': '2 Thessalonians',
+ '1 timothy': '1 Timothy', '2 timothy': '2 Timothy', 'titus': 'Titus', 'philemon': 'Philemon',
+ 'hebrews': 'Hebrews', 'james': 'James', '1 peter': '1 Peter', '2 peter': '2 Peter',
+ '1 john': '1 John', '2 john': '2 John', '3 john': '3 John', 'jude': 'Jude', 'revelation': 'Revelation',
+ 'gen': 'Genesis', 'ex': 'Exodus', 'lev': 'Leviticus', 'num': 'Numbers', 'deut': 'Deuteronomy',
+ 'josh': 'Joshua', 'judg': 'Judges', 'ru': 'Ruth', '1sam': '1 Samuel', '2sam': '2 Samuel',
+ '1ki': '1 Kings', '2ki': '2 Kings', '1chr': '1 Chronicles', '2chr': '2 Chronicles',
+ 'neh': 'Nehemiah', 'est': 'Esther', 'ps': 'Psalms', 'prov': 'Proverbs', 'eccl': 'Ecclesiastes',
+ 'isa': 'Isaiah', 'jer': 'Jeremiah', 'lam': 'Lamentations', 'ezek': 'Ezekiel', 'dan': 'Daniel',
+ 'hos': 'Hosea', 'mic': 'Micah', 'hab': 'Habakkuk', 'zech': 'Zechariah', 'mal': 'Malachi',
+ 'matt': 'Matthew', 'mk': 'Mark', 'lk': 'Luke', 'jn': 'John', 'rom': 'Romans',
+ '1cor': '1 Corinthians', '2cor': '2 Corinthians', 'gal': 'Galatians', 'eph': 'Ephesians',
+ 'phil': 'Philippians', 'col': 'Colossians', '1thess': '1 Thessalonians', '2thess': '2 Thessalonians',
+ '1tim': '1 Timothy', '2tim': '2 Timothy', 'tit': 'Titus', 'heb': 'Hebrews', 'jas': 'James',
+ '1pet': '1 Peter', '2pet': '2 Peter', '1jn': '1 John', '2jn': '2 John', '3jn': '3 John', 'rev': 'Revelation'
+ };
+
+ function capitalizeBook(name) {
+ return bookMap[name.toLowerCase()] || name;
+ }
+
+ // Try to parse as verse reference and return URL, or null
+ function parseVerseReference(input) {
+ // Book Chapter:Verse
+ var match = input.match(/^(.+)\s+(\d+):(\d+)$/i);
+ if (match) {
+ var book = capitalizeBook(match[1].trim());
+ return '/book/' + encodeURIComponent(book) + '/chapter/' + match[2] + '/verse/' + match[3];
+ }
+ // Book Chapter
+ match = input.match(/^(.+)\s+(\d+)$/i);
+ if (match) {
+ var book = capitalizeBook(match[1].trim());
+ return '/book/' + encodeURIComponent(book) + '/chapter/' + match[2];
+ }
+ return null;
+ }
+
+ // Category labels
+ var categoryLabels = {
+ books: 'Books',
+ verses: 'Verses',
+ topics: 'Topics',
+ resources: 'Resources',
+ stories: 'Stories',
+ plans: 'Reading Plans'
+ };
+
+ // Render search results
+ function renderResults(data) {
+ var results = data.results;
+ var html = '';
+ currentResults = [];
+
+ // Check if query looks like a verse reference
+ var verseUrl = parseVerseReference(data.query);
+ if (verseUrl) {
+ html += '
';
+ selectedIndex = 0;
+ }
+
+ if (Object.keys(results).length === 0 && !verseUrl) {
+ html = 'No results found
';
+ } else {
+ // Render categories in specific order: books, topics, resources, stories, plans, verses
+ var categoryOrder = ['books', 'topics', 'resources', 'stories', 'plans', 'verses'];
+ categoryOrder.forEach(function(category) {
+ if (results[category] && results[category].length > 0) {
+ html += '';
+ html += '
' + (categoryLabels[category] || category) + '
';
+
+ results[category].forEach(function(item) {
+ var title = item.name || item.title || item.reference;
+ var meta = '';
+ if (item.text) meta = item.text;
+ else if (item.category) meta = item.category;
+
+ currentResults.push(item.url);
+ html += '
';
+ html += '' + title + ' ';
+ if (meta) html += '' + meta + ' ';
+ html += ' ';
+ });
+
+ html += '
';
+ }
+ });
+
+ // Add "View all results" link
+ html += 'View all verse results → ';
+ }
+
+ dropdown.innerHTML = html;
+ dropdown.classList.add('show');
+ if (!verseUrl) selectedIndex = -1;
+ }
+
+ // Perform search
+ function doSearch(query) {
+ if (query.length < 2) {
+ dropdown.classList.remove('show');
+ return;
+ }
+
+ dropdown.innerHTML = 'Searching...
';
+ dropdown.classList.add('show');
+
+ fetch('/api/universal-search?q=' + encodeURIComponent(query) + '&limit=4')
+ .then(function(r) { return r.json(); })
+ .then(renderResults)
+ .catch(function() {
+ dropdown.innerHTML = 'Search error
';
+ });
+ }
+
+ // Input handler with debounce
+ searchInput.addEventListener('input', function() {
+ var query = this.value.trim();
+ clearTimeout(searchTimeout);
+
+ if (query.length < 2) {
+ dropdown.classList.remove('show');
+ return;
+ }
+
+ searchTimeout = setTimeout(function() {
+ doSearch(query);
+ }, 150);
+ });
+
+ // Keyboard navigation
+ searchInput.addEventListener('keydown', function(e) {
+ var items = dropdown.querySelectorAll('.search-result-item');
+
+ if (e.key === 'ArrowDown') {
+ e.preventDefault();
+ selectedIndex = Math.min(selectedIndex + 1, items.length - 1);
+ updateSelection(items);
+ } else if (e.key === 'ArrowUp') {
+ e.preventDefault();
+ selectedIndex = Math.max(selectedIndex - 1, -1);
+ updateSelection(items);
+ } else if (e.key === 'Enter') {
+ e.preventDefault();
+ if (selectedIndex >= 0 && currentResults[selectedIndex]) {
+ window.location.href = currentResults[selectedIndex];
+ } else {
+ // Try verse reference first, then search
+ var verseUrl = parseVerseReference(this.value.trim());
+ if (verseUrl) {
+ window.location.href = verseUrl;
+ } else if (this.value.trim()) {
+ window.location.href = '/search?q=' + encodeURIComponent(this.value.trim());
+ }
+ }
+ } else if (e.key === 'Escape') {
+ dropdown.classList.remove('show');
+ this.blur();
+ }
+ });
+
+ function updateSelection(items) {
+ items.forEach(function(item, i) {
+ item.classList.toggle('selected', i === selectedIndex);
+ });
+ }
+
+ // Close dropdown when clicking outside
+ document.addEventListener('click', function(e) {
+ if (!e.target.closest('.sidebar-search')) {
+ dropdown.classList.remove('show');
+ }
+ });
+
+ // Reopen on focus if there's a query
+ searchInput.addEventListener('focus', function() {
+ if (this.value.trim().length >= 2) {
+ doSearch(this.value.trim());
+ }
+ });
+})();
+
+// Global viewport helpers for keyboard navigation
+window.KJVNav = {
+ isInViewport: function(el) {
+ if (!el) return false;
+ var rect = el.getBoundingClientRect();
+ return rect.top < window.innerHeight && rect.bottom > 0;
+ },
+ findFirstVisibleIndex: function(elements) {
+ for (var i = 0; i < elements.length; i++) {
+ if (this.isInViewport(elements[i])) return i;
+ }
+ return 0;
+ },
+ isSelectionOffScreen: function(elements, selectedIndex) {
+ if (selectedIndex < 0) return true;
+ if (selectedIndex >= elements.length) return true;
+ return !this.isInViewport(elements[selectedIndex]);
+ },
+ // Sidebar navigation state
+ sidebarActive: false,
+ sidebarIndex: -1,
+ sidebarLinks: [],
+ currentPageNav: null,
+
+ // Activate sidebar navigation with 'n' key
+ initSidebarNav: function() {
+ var self = this;
+ var sidebar = document.querySelector('.nav-sidebar');
+ if (!sidebar) return;
+
+ // Get all navigable links in sidebar
+ self.sidebarLinks = Array.from(sidebar.querySelectorAll('a'));
+
+ function clearSidebarSelection() {
+ self.sidebarLinks.forEach(function(link) {
+ link.style.outline = '';
+ link.style.background = '';
+ });
+ }
+
+ function selectSidebarLink(index) {
+ clearSidebarSelection();
+ if (self.sidebarLinks.length === 0) return;
+ self.sidebarIndex = Math.max(0, Math.min(index, self.sidebarLinks.length - 1));
+ var link = self.sidebarLinks[self.sidebarIndex];
+ link.style.outline = '2px solid #4a7c59';
+ link.style.background = 'rgba(74, 124, 89, 0.1)';
+ link.scrollIntoView({ behavior: 'auto', block: 'center' });
+
+ // Expand parent details if collapsed
+ var details = link.closest('details');
+ if (details && !details.open) {
+ details.open = true;
+ }
+ }
+
+ function exitSidebar() {
+ clearSidebarSelection();
+ self.sidebarActive = false;
+ self.sidebarIndex = -1;
+ // Clear page nav selection if exists
+ if (self.currentPageNav && self.currentPageNav.clearSelection) {
+ self.currentPageNav.clearSelection();
+ }
+ }
+
+ document.addEventListener('keydown', function(e) {
+ if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') return;
+
+ // 'n' to enter sidebar nav mode
+ if (e.key === 'n' && !self.sidebarActive) {
+ e.preventDefault();
+ self.sidebarActive = true;
+ // Clear any page content selection
+ if (self.currentPageNav && self.currentPageNav.clearSelection) {
+ self.currentPageNav.clearSelection();
+ }
+ // Start at first link or current page link
+ var currentLink = sidebar.querySelector('a.current');
+ var startIndex = currentLink ? self.sidebarLinks.indexOf(currentLink) : 0;
+ selectSidebarLink(startIndex >= 0 ? startIndex : 0);
+ return;
+ }
+
+ // Handle sidebar navigation when active
+ if (self.sidebarActive) {
+ if (e.key === 'ArrowDown' || e.key === 'j') {
+ e.preventDefault();
+ selectSidebarLink(self.sidebarIndex + 1);
+ } else if (e.key === 'ArrowUp' || e.key === 'k') {
+ e.preventDefault();
+ selectSidebarLink(self.sidebarIndex - 1);
+ } else if (e.key === 'Enter') {
+ e.preventDefault();
+ if (self.sidebarIndex >= 0 && self.sidebarLinks[self.sidebarIndex]) {
+ window.location.href = self.sidebarLinks[self.sidebarIndex].href;
+ }
+ } else if (e.key === 'Escape') {
+ e.preventDefault();
+ exitSidebar();
+ } else if (e.key === 'ArrowRight' || e.key === 'l') {
+ // Expand details or go to link
+ e.preventDefault();
+ if (self.sidebarIndex >= 0) {
+ var link = self.sidebarLinks[self.sidebarIndex];
+ var details = link.closest('details');
+ var summary = link.closest('summary');
+ if (summary && details && !details.open) {
+ details.open = true;
+ } else {
+ window.location.href = link.href;
+ }
+ }
+ } else if (e.key === 'ArrowLeft' || e.key === 'h') {
+ // Collapse details or exit sidebar
+ e.preventDefault();
+ if (self.sidebarIndex >= 0) {
+ var link = self.sidebarLinks[self.sidebarIndex];
+ var details = link.closest('details');
+ if (details && details.open) {
+ details.open = false;
+ } else {
+ exitSidebar();
+ }
+ } else {
+ exitSidebar();
+ }
+ }
+ }
+ });
+ },
+
+ // Simple linear keyboard navigation - just pass a CSS selector
+ initSimpleNav: function(selector, options) {
+ options = options || {};
+ var elements = Array.from(document.querySelectorAll(selector));
+ var selectedIndex = -1;
+ var pdfSelector = options.pdfSelector || '[class*="-download-btn"]';
+ var self = this;
+
+ function clearSelection() {
+ if (selectedIndex >= 0 && selectedIndex < elements.length) {
+ elements[selectedIndex].style.outline = '';
+ elements[selectedIndex].style.outlineOffset = '';
+ elements[selectedIndex].classList.remove('selected');
+ }
+ }
+
+ function selectElement(index) {
+ // Exit sidebar mode if active
+ if (self.sidebarActive) {
+ self.sidebarActive = false;
+ self.sidebarIndex = -1;
+ document.querySelectorAll('.nav-sidebar a').forEach(function(link) {
+ link.style.outline = '';
+ link.style.background = '';
+ });
+ }
+ clearSelection();
+ if (elements.length === 0) return;
+ selectedIndex = Math.max(0, Math.min(index, elements.length - 1));
+ elements[selectedIndex].style.outline = '2px solid #4a7c59';
+ elements[selectedIndex].style.outlineOffset = '8px';
+ elements[selectedIndex].classList.add('selected');
+ elements[selectedIndex].scrollIntoView({ behavior: 'auto', block: 'center' });
+ }
+
+ // Store reference for sidebar to clear
+ var navInstance = { elements: elements, selectElement: selectElement, clearSelection: clearSelection };
+ self.currentPageNav = navInstance;
+
+ document.addEventListener('keydown', function(e) {
+ if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') return;
+ // Don't handle if sidebar is active (except 'n' is handled above)
+ if (self.sidebarActive) return;
+
+ if (e.key === 'ArrowDown' || e.key === 'j') {
+ e.preventDefault();
+ if (selectedIndex < 0 || KJVNav.isSelectionOffScreen(elements, selectedIndex)) {
+ selectElement(KJVNav.findFirstVisibleIndex(elements));
+ } else {
+ selectElement(selectedIndex + 1);
+ }
+ } else if (e.key === 'ArrowUp' || e.key === 'k') {
+ e.preventDefault();
+ if (selectedIndex < 0 || KJVNav.isSelectionOffScreen(elements, selectedIndex)) {
+ selectElement(KJVNav.findFirstVisibleIndex(elements));
+ } else {
+ selectElement(selectedIndex - 1);
+ }
+ } else if (e.key === 'ArrowLeft' || e.key === 'h') {
+ e.preventDefault();
+ history.back();
+ } else if (e.key === 'Enter') {
+ e.preventDefault();
+ if (selectedIndex >= 0) {
+ var el = elements[selectedIndex];
+ var link = el.tagName === 'A' ? el : el.querySelector('a');
+ if (link) window.location.href = link.href;
+ }
+ } else if (e.key === 'Escape') {
+ e.preventDefault();
+ clearSelection();
+ selectedIndex = -1;
+ } else if (e.key === 'p') {
+ e.preventDefault();
+ var pdfBtn = document.querySelector(pdfSelector);
+ if (pdfBtn) window.location.href = pdfBtn.href;
+ } else if (e.key === ' ') {
+ e.preventDefault();
+ if (selectedIndex >= 0 && window.KJVSpeech) {
+ var text = elements[selectedIndex].textContent || elements[selectedIndex].innerText;
+ KJVSpeech.speak(text);
+ }
+ }
+ });
+
+ return navInstance;
+ }
+};
+
+// Initialize sidebar navigation globally
+document.addEventListener('DOMContentLoaded', function() {
+ KJVNav.initSidebarNav();
+});
+
+// Text-to-speech for any selected content
+window.KJVSpeech = {
+ utterance: null,
+ speaking: false,
+
+ speak: function(text) {
+ if (!('speechSynthesis' in window)) {
+ console.log('Speech synthesis not supported');
+ return;
+ }
+
+ // Stop any current speech
+ this.stop();
+
+ // Clean up the text - remove verse numbers at start, collapse whitespace
+ text = text.replace(/^\s*\d+\s*/, '').replace(/\s+/g, ' ').trim();
+
+ if (!text) return;
+
+ this.utterance = new SpeechSynthesisUtterance(text);
+ this.utterance.rate = 0.9;
+ this.utterance.pitch = 1;
+
+ // Try to use a good English voice
+ var voices = speechSynthesis.getVoices();
+ var englishVoice = voices.find(function(v) {
+ return v.lang.startsWith('en') && v.name.includes('Daniel');
+ }) || voices.find(function(v) {
+ return v.lang.startsWith('en-GB');
+ }) || voices.find(function(v) {
+ return v.lang.startsWith('en');
+ });
+
+ if (englishVoice) {
+ this.utterance.voice = englishVoice;
+ }
+
+ this.speaking = true;
+
+ this.utterance.onend = function() {
+ KJVSpeech.speaking = false;
+ };
+
+ this.utterance.onerror = function() {
+ KJVSpeech.speaking = false;
+ };
+
+ speechSynthesis.speak(this.utterance);
+ },
+
+ stop: function() {
+ if ('speechSynthesis' in window) {
+ speechSynthesis.cancel();
+ }
+ this.speaking = false;
+ },
+
+ toggle: function(text) {
+ if (this.speaking) {
+ this.stop();
+ } else {
+ this.speak(text);
+ }
+ },
+
+ // Get text from the currently selected element (green box)
+ getSelectedText: function() {
+ // Find element with our green selection outline
+ var selected = document.querySelector('[style*="outline: 2px solid"]') ||
+ document.querySelector('[style*="outline:2px solid"]');
+
+ if (selected) {
+ return selected.textContent || selected.innerText;
+ }
+
+ // Fallback: Try specific verse selectors
+ var verseEl = document.querySelector('.verse-text-content') ||
+ document.querySelector('.verse-display .text') ||
+ document.querySelector('.verse-text') ||
+ document.querySelector('[data-verse-text]');
+
+ if (verseEl) {
+ return verseEl.textContent || verseEl.innerText;
+ }
+
+ return null;
+ }
+};
+
+// Simple resource reader state (for resource pages)
+window.KJVResourceSpeech = {
+ speaking: false,
+ utterance: null,
+ suppressSpace: false
+};
+
+// Load voices (they may not be available immediately)
+if ('speechSynthesis' in window) {
+ speechSynthesis.getVoices();
+ speechSynthesis.onvoiceschanged = function() {
+ speechSynthesis.getVoices();
+ };
+}
+
+// Default resource reader unless explicitly disabled
+document.addEventListener('DOMContentLoaded', function() {
+ if (document.body && !document.body.dataset.resourceReader) {
+ document.body.dataset.resourceReader = 'true';
+ }
+});
+
+// Keyboard shortcuts
+document.addEventListener('keydown', function(e) {
+ // Don't trigger if user is typing in an input field
+ if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') {
+ // Allow Escape to clear focus
+ if (e.key === 'Escape') {
+ e.target.blur();
+ }
+ return;
+ }
+
+ // Cmd/Ctrl + D: Toggle dark mode
+ if ((e.metaKey || e.ctrlKey) && e.key === 'd') {
+ e.preventDefault();
+ toggleDarkMode();
+ }
+
+ // Cmd/Ctrl + B: Toggle sidebar
+ if ((e.metaKey || e.ctrlKey) && e.key === 'b') {
+ e.preventDefault();
+ var toggle = document.getElementById('sidebar-toggle');
+ if (toggle) {
+ toggle.checked = !toggle.checked;
+ toggle.dispatchEvent(new Event('change'));
+ }
+ }
+
+ // Cmd/Ctrl + K or /: Focus search
+ if (((e.metaKey || e.ctrlKey) && e.key === 'k') || e.key === '/') {
+ e.preventDefault();
+ window.location.href = '/search';
+ }
+
+ // Single key shortcuts for navigation (only work without modifiers)
+ if (!e.metaKey && !e.ctrlKey && !e.altKey) {
+ switch(e.key) {
+ case '1':
+ e.preventDefault();
+ window.location.href = '/';
+ break;
+ case '`':
+ e.preventDefault();
+ var toggle = document.getElementById('sidebar-toggle');
+ if (toggle) {
+ toggle.checked = !toggle.checked;
+ toggle.dispatchEvent(new Event('change'));
+ }
+ break;
+ case 'g':
+ e.preventDefault();
+ showVerseLookup();
+ break;
+ case 'r':
+ e.preventDefault();
+ window.location.href = '/resources';
+ break;
+ case 'b':
+ e.preventDefault();
+ window.location.href = '/books';
+ break;
+ case 's':
+ e.preventDefault();
+ window.location.href = '/stories';
+ break;
+ case '/':
+ e.preventDefault();
+ window.location.href = '/search';
+ break;
+ case 't':
+ e.preventDefault();
+ window.location.href = '/topics';
+ break;
+ case 'p':
+ e.preventDefault();
+ window.location.href = '/reading-plans';
+ break;
+ case 'f':
+ e.preventDefault();
+ window.location.href = '/family-tree';
+ break;
+ case 'c':
+ e.preventDefault();
+ window.location.href = '/strongs';
+ break;
+ case 'v':
+ e.preventDefault();
+ window.location.href = '/verse-of-the-day';
+ break;
+ case '.':
+ e.preventDefault();
+ window.location.href = '/random-verse';
+ break;
+ case '?':
+ showKeyboardHelp();
+ break;
+ case 'R':
+ e.preventDefault();
+ toggleRedLetters();
+ break;
+ case ' ':
+ // Space: Read aloud selected text (with optional resource-reader handling)
+ if (document.body && document.body.dataset && document.body.dataset.resourceReader === 'false') {
+ // explicitly disabled
+ } else if (document.body) {
+ e.preventDefault();
+ if (window.KJVResourceSpeech.suppressSpace) return;
+ if (!('speechSynthesis' in window)) return;
+ // If something is currently speaking or queued, stop it instead of starting a new read
+ if (window.KJVResourceSpeech.utterance && (speechSynthesis.speaking || speechSynthesis.pending || speechSynthesis.paused)) {
+ speechSynthesis.cancel();
+ window.KJVResourceSpeech.speaking = false;
+ window.KJVResourceSpeech.utterance = null;
+ window.KJVResourceSpeech.suppressSpace = true;
+ return;
+ }
+ if (window.KJVResourceSpeech.speaking) {
+ speechSynthesis.cancel();
+ window.KJVResourceSpeech.speaking = false;
+ window.KJVResourceSpeech.utterance = null;
+ window.KJVResourceSpeech.suppressSpace = true;
+ return;
+ }
+ var highlighted = document.querySelector('[style*=\"outline: 2px solid\"]:not(.toc):not(.breadcrumb):not(.chapters-section)') ||
+ document.querySelector('[style*=\"outline:2px solid\"]:not(.toc):not(.breadcrumb):not(.chapters-section)') ||
+ document.querySelector('.selected:not(.toc):not(.breadcrumb):not(.chapters-section)');
+ var combined = '';
+ if (highlighted) {
+ var hclone = highlighted.cloneNode(true);
+ hclone.querySelectorAll('.sidenote, .marginnote, .sidenote-number, .margin-toggle, .breadcrumb, .toc, .chapters-section, script, style').forEach(function(el) { el.remove(); });
+ combined = (hclone.textContent || hclone.innerText || '').trim();
+ } else {
+ var article = document.querySelector('article');
+ if (article) {
+ // If this is a card grid, concatenate the highlighted or first card
+ var selectedCard = article.querySelector('.resource-card[style*=\"outline\"]') || article.querySelector('.resource-card');
+ if (selectedCard) {
+ var cclone = selectedCard.cloneNode(true);
+ cclone.querySelectorAll('.sidenote, .marginnote, .sidenote-number, .margin-toggle, .breadcrumb, .toc, .chapters-section, script, style').forEach(function(el) { el.remove(); });
+ combined = (cclone.textContent || cclone.innerText || '').trim();
+ } else {
+ // Prefer reading substantive text blocks instead of the whole article
+ var textBlocks = Array.from(article.querySelectorAll('h1, h2, h3, p, li, blockquote, .intro-text, .verse-text, .resource-description, .resource-description-body, .resource-item-description, .parable-description, .angel-description, .covenant-description, .festival-description, .prophet-description, .fruit-description, .name-description, .woman-description, .apostle-description'))
+ .filter(function(el) {
+ if (el.closest('.toc') || el.closest('.chapters-section') || el.closest('.breadcrumb')) return false;
+ var txt = (el.textContent || el.innerText || '').trim();
+ return txt.length > 120;
+ })
+ .map(function(el) {
+ var clone = el.cloneNode(true);
+ clone.querySelectorAll('.sidenote, .marginnote, .sidenote-number, .margin-toggle, .breadcrumb, .toc, .chapters-section, script, style').forEach(function(x) { x.remove(); });
+ return (clone.textContent || clone.innerText || '').trim();
+ })
+ .filter(Boolean);
+ if (textBlocks.length > 0) {
+ combined = textBlocks.join('\\n\\n');
+ } else {
+ var aclone = article.cloneNode(true);
+ aclone.querySelectorAll('.sidenote, .marginnote, .sidenote-number, .margin-toggle, .breadcrumb, .toc, .chapters-section, script, style').forEach(function(el) { el.remove(); });
+ combined = (aclone.textContent || aclone.innerText || '').trim();
+ }
+ }
+ }
+ }
+ if (combined) {
+ var utter = new SpeechSynthesisUtterance(combined);
+ var voices = speechSynthesis.getVoices();
+ var englishVoice = voices.find(function(v) { return v.lang && v.lang.toLowerCase().startsWith('en') && v.name.includes('Daniel'); }) ||
+ voices.find(function(v) { return v.lang && v.lang.toLowerCase().startsWith('en-gb'); }) ||
+ voices.find(function(v) { return v.lang && v.lang.toLowerCase().startsWith('en'); });
+ if (englishVoice) utter.voice = englishVoice;
+ utter.onend = function() {
+ window.KJVResourceSpeech.speaking = false;
+ window.KJVResourceSpeech.utterance = null;
+ };
+ utter.onerror = function() {
+ window.KJVResourceSpeech.speaking = false;
+ window.KJVResourceSpeech.utterance = null;
+ };
+ window.KJVResourceSpeech.speaking = true;
+ window.KJVResourceSpeech.utterance = utter;
+ window.KJVResourceSpeech.suppressSpace = false;
+ speechSynthesis.cancel();
+ speechSynthesis.speak(utter);
+ }
+ } else {
+ var selectedText = KJVSpeech.getSelectedText();
+ if (selectedText) {
+ e.preventDefault();
+ KJVSpeech.toggle(selectedText);
+ }
+ }
+ break;
+ }
+ }
+});
+
+// Clear suppression after spacebar is released
+document.addEventListener('keyup', function(e) {
+ if (e.key === ' ') {
+ window.KJVResourceSpeech.suppressSpace = false;
+ }
+});
+
+// Quick verse lookup
+function showVerseLookup() {
+ var reference = prompt('Enter verse reference (e.g., John 3:16, Psalm 23, Genesis 1):');
+ if (!reference) return;
+
+ reference = reference.trim();
+
+ // Try to match: Book Chapter:Verse
+ var match = reference.match(/^(.+?)\s+(\d+):(\d+)$/i);
+ if (match) {
+ var book = match[1].trim();
+ var chapter = match[2];
+ var verse = match[3];
+ window.location.href = '/book/' + encodeURIComponent(book) + '/chapter/' + chapter + '/verse/' + verse;
+ return;
+ }
+
+ // Try to match: Book Chapter
+ match = reference.match(/^(.+?)\s+(\d+)$/i);
+ if (match) {
+ var book = match[1].trim();
+ var chapter = match[2];
+ window.location.href = '/book/' + encodeURIComponent(book) + '/chapter/' + chapter;
+ return;
+ }
+
+ // Try to match: Book
+ match = reference.match(/^(.+)$/i);
+ if (match) {
+ var book = match[1].trim();
+ window.location.href = '/book/' + encodeURIComponent(book);
+ return;
+ }
+}
+
+// Keyboard shortcuts help modal
+function showKeyboardHelp() {
+ // Remove existing modal if present
+ var existingModal = document.getElementById('keyboard-help-modal');
+ if (existingModal) {
+ existingModal.remove();
+ return;
+ }
+
+ var modal = document.createElement('div');
+ modal.id = 'keyboard-help-modal';
+ modal.innerHTML =
+ '
' +
+ '' +
+ '
× ' +
+ '
Keyboard Shortcuts ' +
+ '
' +
+ '
' +
+ '
Navigation ' +
+ '
b Books
' +
+ '
s Stories
' +
+ '
r Resources
' +
+ '
t Topics
' +
+ '
p Reading Plans
' +
+ '
f Family Tree
' +
+ '
c Strong\'s Concordance
' +
+ '
v Verse of the Day
' +
+ '
. Random Verse
' +
+ '
g Quick verse lookup
' +
+ '
' +
+ '
' +
+ '
Page Navigation ' +
+ '
↑ / k Previous item
' +
+ '
↓ / j Next item
' +
+ '
← / h Go back
' +
+ '
→ / l Next page
' +
+ '
Enter Select / Open
' +
+ '
' +
+ '
' +
+ '
Other ' +
+ '
Space Read aloud
' +
+ '
` Toggle sidebar
' +
+ '
⌘ +D Toggle dark mode
' +
+ '
R Toggle red letters
' +
+ '
/ Search
' +
+ '
? Show this help
' +
+ '
Esc Close / Clear focus
' +
+ '
' +
+ '
' +
+ '
';
+
+ // Add styles
+ var style = document.createElement('style');
+ style.textContent =
+ '#keyboard-help-modal { position: fixed; top: 0; left: 0; right: 0; bottom: 0; z-index: 10000; display: flex; align-items: center; justify-content: center; }' +
+ '.keyboard-help-backdrop { position: absolute; top: 0; left: 0; right: 0; bottom: 0; background: rgba(0,0,0,0.5); }' +
+ '.keyboard-help-content { position: relative; background: var(--bg-color); border-radius: 8px; padding: 1.5rem 2rem; max-width: 700px; max-height: 90vh; overflow-y: auto; box-shadow: 0 10px 40px rgba(0,0,0,0.3); }' +
+ '.keyboard-help-content h2 { margin: 0 0 1rem 0; font-size: 1.4rem; font-weight: 600; }' +
+ '.keyboard-help-close { position: absolute; top: 0.75rem; right: 0.75rem; background: none; border: none; font-size: 1.5rem; cursor: pointer; color: var(--text-secondary); padding: 0.25rem 0.5rem; line-height: 1; }' +
+ '.keyboard-help-close:hover { color: var(--text-color); }' +
+ '.keyboard-help-columns { display: grid; grid-template-columns: repeat(3, 1fr); gap: 1.5rem; }' +
+ '.keyboard-help-section h3 { font-size: 0.85rem; font-weight: 600; text-transform: uppercase; letter-spacing: 0.05em; color: var(--text-secondary); margin: 0 0 0.75rem 0; padding-bottom: 0.5rem; border-bottom: 1px solid var(--border-color); }' +
+ '.shortcut { display: flex; align-items: center; gap: 0.5rem; margin: 0.4rem 0; font-size: 0.9rem; }' +
+ '.shortcut kbd { display: inline-block; padding: 0.15rem 0.4rem; font-family: inherit; font-size: 0.8rem; background: var(--code-bg); border: 1px solid var(--border-color-darker); border-radius: 3px; min-width: 1.5rem; text-align: center; }' +
+ '.shortcut span { color: var(--text-secondary); }' +
+ '@media (max-width: 700px) { .keyboard-help-columns { grid-template-columns: 1fr; } .keyboard-help-content { margin: 1rem; padding: 1rem 1.5rem; } }';
+ modal.appendChild(style);
+
+ document.body.appendChild(modal);
+
+ // Close handlers
+ modal.querySelector('.keyboard-help-backdrop').addEventListener('click', function() {
+ modal.remove();
+ });
+ modal.querySelector('.keyboard-help-close').addEventListener('click', function() {
+ modal.remove();
+ });
+ document.addEventListener('keydown', function closeOnEsc(e) {
+ if (e.key === 'Escape') {
+ modal.remove();
+ document.removeEventListener('keydown', closeOnEsc);
+ }
+ });
+}
+
+// Verse tooltip functionality
+(function() {
+ // Create tooltip element
+ var tooltip = document.createElement('div');
+ tooltip.className = 'verse-tooltip';
+ document.body.appendChild(tooltip);
+
+ // Cache for fetched verses
+ var verseCache = {};
+
+ // Parse verse URL to extract book, chapter, and verse
+ function parseVerseUrl(url) {
+ // Try to match single verse URL (old format): /book/John/chapter/3/verse/16
+ var match = url.match(/\/book\/([^\/]+)\/chapter\/(\d+)\/verse\/(\d+)/);
+ if (match) {
+ return {
+ book: decodeURIComponent(match[1]),
+ chapter: match[2],
+ verse: match[3],
+ verseEnd: null,
+ cacheKey: match[1] + '_' + match[2] + '_' + match[3],
+ isRange: false
+ };
+ }
+
+ // Try to match verse range URL: /book/Proverbs/chapter/31#verse-26-28
+ match = url.match(/\/book\/([^\/]+)\/chapter\/(\d+)#verse-(\d+)-(\d+)/);
+ if (match) {
+ return {
+ book: decodeURIComponent(match[1]),
+ chapter: match[2],
+ verse: match[3],
+ verseEnd: match[4],
+ cacheKey: match[1] + '_' + match[2] + '_' + match[3] + '-' + match[4],
+ isRange: true
+ };
+ }
+
+ // Try to match single verse anchor URL (new format): /book/John/chapter/3#verse-16
+ match = url.match(/\/book\/([^\/]+)\/chapter\/(\d+)#verse-(\d+)$/);
+ if (match) {
+ return {
+ book: decodeURIComponent(match[1]),
+ chapter: match[2],
+ verse: match[3],
+ verseEnd: null,
+ cacheKey: match[1] + '_' + match[2] + '_' + match[3],
+ isRange: false
+ };
+ }
+
+ return null;
+ }
+
+ // Fetch verse text from server using API
+ async function fetchVerseText(book, chapter, verse, verseEnd, cacheKey) {
+ // Check cache first
+ if (verseCache[cacheKey]) {
+ return verseCache[cacheKey];
+ }
+
+ try {
+ var url;
+
+ if (verseEnd) {
+ // Use verse range API endpoint
+ url = '/api/verse-range/' + encodeURIComponent(book) + '/' + chapter + '/' + verse + '/' + verseEnd;
+ } else {
+ // Use single verse API endpoint
+ url = '/api/verse/' + encodeURIComponent(book) + '/' + chapter + '/' + verse;
+ }
+
+ var response = await fetch(url);
+ if (!response.ok) throw new Error('Failed to fetch verse');
+
+ var data = await response.json();
+
+ // Cache the result
+ verseCache[cacheKey] = {
+ reference: data.reference,
+ text: data.text
+ };
+
+ return verseCache[cacheKey];
+ } catch (error) {
+ console.error('Error fetching verse:', error);
+ return {
+ reference: verseEnd ? book + ' ' + chapter + ':' + verse + '-' + verseEnd : book + ' ' + chapter + ':' + verse,
+ text: 'Error loading verse'
+ };
+ }
+ }
+
+ // Show tooltip
+ function showTooltip(verseData, mouseX, mouseY) {
+ tooltip.innerHTML =
+ '' + verseData.reference + ' ' +
+ '' + verseData.text + ' ';
+
+ // Position tooltip
+ var tooltipRect = tooltip.getBoundingClientRect();
+ var x = mouseX + 15;
+ var y = mouseY + 15;
+
+ // Adjust if tooltip goes off right edge
+ if (x + tooltipRect.width > window.innerWidth) {
+ x = mouseX - tooltipRect.width - 15;
+ }
+
+ // Adjust if tooltip goes off bottom edge
+ if (y + tooltipRect.height > window.innerHeight) {
+ y = mouseY - tooltipRect.height - 15;
+ }
+
+ tooltip.style.left = x + 'px';
+ tooltip.style.top = y + 'px';
+ tooltip.classList.add('show');
+ }
+
+ // Hide tooltip
+ function hideTooltip() {
+ tooltip.classList.remove('show');
+ }
+
+ // Event delegation for verse links
+ document.addEventListener('mouseover', function(e) {
+ var target = e.target;
+
+ // Check if target is a link or inside a link
+ if (target.tagName !== 'A') {
+ target = target.closest('a');
+ }
+
+ if (!target || !target.href) return;
+
+ // Skip if this is a cross-reference link (has its own tooltip system)
+ if (target.classList.contains('cross-ref-link')) return;
+
+ // Skip if this is a verse number link (the number at the start of each verse)
+ if (target.classList.contains('verse-number-link')) return;
+
+ // Skip links in search dropdowns
+ if (target.closest('.search-results-dropdown') || target.closest('.search-dropdown') ||
+ target.closest('.lookup-results-dropdown') || target.closest('.story-search-dropdown')) return;
+
+ // Skip links in concordance results (verse text already shown inline)
+ if (target.closest('.occurrence-reference') || target.closest('.occurrence')) return;
+
+ // Skip links in cross-references section (has its own CSS tooltip system)
+ if (target.closest('.cross-references-section')) return;
+
+ // Skip links in navigation, buttons, actions, headers, and non-content areas
+ if (target.closest('nav') || target.closest('header') || target.closest('footer') ||
+ target.closest('.toc') || target.closest('.breadcrumb') ||
+ target.closest('button') || target.closest('[class*="-btn"]') || target.closest('[class*="-actions"]') ||
+ target.closest('[class*="download"]') || target.closest('[class*="print"]') ||
+ target.closest('.share-container') || target.closest('.share-buttons') ||
+ target.closest('.chapter-nav') || target.closest('.verse-nav') ||
+ target.closest('h1') || target.closest('h2') || target.closest('h3')) return;
+
+ // Only show tooltips for links inside content paragraphs, sections, or article content
+ if (!target.closest('p') && !target.closest('section') && !target.closest('article') &&
+ !target.closest('.verse-item') && !target.closest('.verse-text') &&
+ !target.closest('.intro-text') && !target.closest('.description') &&
+ !target.closest('.sidenote') && !target.closest('.marginnote') &&
+ !target.closest('li')) return;
+
+ var verseInfo = parseVerseUrl(target.pathname + target.hash);
+ if (!verseInfo) return;
+
+ // Show loading state
+ tooltip.innerHTML = 'Loading... ';
+ tooltip.style.left = (e.pageX + 15) + 'px';
+ tooltip.style.top = (e.pageY + 15) + 'px';
+ tooltip.classList.add('show');
+
+ // Fetch and display verse
+ fetchVerseText(verseInfo.book, verseInfo.chapter, verseInfo.verse, verseInfo.verseEnd, verseInfo.cacheKey)
+ .then(function(verseData) {
+ // Only show if still hovering
+ if (target.matches(':hover')) {
+ showTooltip(verseData, e.pageX, e.pageY);
+ }
+ });
+
+ // Track mouse movement for tooltip positioning
+ var mouseMoveHandler = function(e) {
+ if (tooltip.classList.contains('show')) {
+ var x = e.pageX + 15;
+ var y = e.pageY + 15;
+ tooltip.style.left = x + 'px';
+ tooltip.style.top = y + 'px';
+ }
+ };
+
+ target.addEventListener('mousemove', mouseMoveHandler);
+
+ // Hide tooltip on mouse leave
+ target.addEventListener('mouseleave', function() {
+ hideTooltip();
+ target.removeEventListener('mousemove', mouseMoveHandler);
+ }, { once: true });
+ });
+})();
+
+// Site-wide verse linking
+(function() {
+ function linkVerseReferences(element) {
+ if (!element) return;
+
+ // Get all text nodes, but skip those inside anchors (already linked)
+ const walker = document.createTreeWalker(element, NodeFilter.SHOW_TEXT, {
+ acceptNode: function(node) {
+ // Skip text nodes that are inside anchor tags (already linked)
+ if (node.parentNode && node.parentNode.tagName === 'A') {
+ return NodeFilter.FILTER_REJECT;
+ }
+ return NodeFilter.FILTER_ACCEPT;
+ }
+ }, false);
+ const textNodes = [];
+ let node;
+ while (node = walker.nextNode()) {
+ textNodes.push(node);
+ }
+
+ textNodes.forEach(function(textNode) {
+ let text = textNode.textContent;
+ let changed = false;
+
+ // First, handle comma-separated verse lists like "Psalms 7:17, 9:2, 18:13"
+ text = text.replace(/\b(\d?\s?[A-Z][a-z]+(?:\s+[A-Z][a-z]+)?)\s+((?:\d+:\d+(?:-\d+)?(?:\s*,\s*)?)+)/g, function(match, book, verseList) {
+ book = book.trim();
+
+ if (!verseList.includes(',')) {
+ return match;
+ }
+
+ changed = true;
+
+ var verses = verseList.split(/\s*,\s*/);
+ var links = verses.map(function(verseRef) {
+ verseRef = verseRef.trim();
+ var parts = verseRef.match(/^(\d+):(\d+)(?:-(\d+))?$/);
+ if (parts) {
+ var chapter = parts[1];
+ var verseStart = parts[2];
+ var verseEnd = parts[3];
+
+ if (verseEnd) {
+ return '' + book + ' ' + chapter + ':' + verseStart + '-' + verseEnd + ' ';
+ } else {
+ // Use chapter anchor instead of verse page
+ return '' + book + ' ' + chapter + ':' + verseStart + ' ';
+ }
+ }
+ return verseRef;
+ });
+
+ return links.join(', ');
+ });
+
+ // Then handle individual verse references like "John 3:16" or "Romans 8:28-29"
+ text = text.replace(/\b(\d?\s?[A-Z][a-z]+(?:\s+[A-Z][a-z]+)?)\s+(\d+):(\d+)(?:-(\d+))?\b/g, function(match, book, chapter, verseStart, verseEnd) {
+ changed = true;
+ book = book.trim();
+ if (verseEnd) {
+ return '' + match + ' ';
+ } else {
+ // Use chapter anchor instead of verse page
+ return '' + match + ' ';
+ }
+ });
+
+ if (changed) {
+ const span = document.createElement('span');
+ span.innerHTML = text;
+ textNode.parentNode.replaceChild(span, textNode);
+ while (span.firstChild) {
+ span.parentNode.insertBefore(span.firstChild, span);
+ }
+ span.parentNode.removeChild(span);
+ }
+ });
+ }
+
+ // Link verses in common content areas
+ document.querySelectorAll('.intro-text, .prophet-description, .angel-description, .covenant-description, p, li').forEach(function(element) {
+ // Skip if already processed or if it's in the sidebar
+ if (element.closest('.nav-sidebar') || element.dataset.verseLinked) {
+ return;
+ }
+ element.dataset.verseLinked = 'true';
+ linkVerseReferences(element);
+ });
+})();
+
diff --git a/kjvstudy_org/templates/base.html b/kjvstudy_org/templates/base.html
index 0105ba9..30cf7ff 100644
--- a/kjvstudy_org/templates/base.html
+++ b/kjvstudy_org/templates/base.html
@@ -1416,1263 +1416,9 @@
+
+