Add collapsible URL list with keyboard navigation

- Collapsible category sections for cached pages
- Categories: Main Pages, Bible Chapters, Verse Commentary,
  Interlinear, Stories, Topics, Study Guides, etc.
- Clean URL display: %20 replaced with spaces, paths cleaned up
- Keyboard navigation: j/k to navigate, Enter/Space to expand,
  h/l for left/right, Esc to back out, u to enter URL nav mode
- Increased download concurrency from 50 to 100 workers
- Progress notifications every 250 completions (was 100)

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

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2025-11-30 01:51:58 -05:00
parent 6dc6c60ec6
commit be2a2f0c62
2 changed files with 354 additions and 16 deletions
+2 -2
View File
@@ -169,8 +169,8 @@ async function startBackgroundCaching() {
});
// Concurrent pool - keep N requests in flight at all times
const CONCURRENCY = 50; // Number of concurrent requests
const PROGRESS_INTERVAL = 100; // Notify every N completions
const CONCURRENCY = 100; // Number of concurrent requests
const PROGRESS_INTERVAL = 250; // Notify every N completions
let nextIndex = 0;
let lastNotified = 0;
+352 -14
View File
@@ -330,6 +330,77 @@
font-size: 1rem;
color: var(--text-secondary);
}
/* Collapsible URL sections */
.url-categories {
margin: 1rem 0;
}
.url-category {
margin-bottom: 0.5rem;
border: 1px solid var(--border-color);
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);
cursor: pointer;
user-select: none;
font-weight: 500;
}
.url-category-header:hover {
background: var(--text-secondary);
color: var(--bg-color);
}
.url-category.selected .url-category-header {
outline: 2px solid var(--success-color);
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);
}
.url-category.expanded .url-category-content {
display: block;
}
.url-item {
display: block;
padding: 0.4rem 0.75rem;
color: var(--link-color);
text-decoration: none;
font-size: 0.9rem;
border-radius: 4px;
}
.url-item:hover {
background: var(--border-color);
}
.url-item.selected {
background: var(--success-color);
color: white;
}
.url-count {
font-size: 0.8rem;
color: var(--text-secondary);
font-weight: normal;
}
.url-nav-hint {
font-size: 0.8rem;
color: var(--text-secondary);
margin-top: 0.5rem;
font-style: italic;
}
</style>
</head>
<body>
@@ -407,7 +478,8 @@
</dl>
</div>
<h3>Cached Pages <span id="cached-count">0</span></h3>
<div id="cached-links" class="cached-links">
<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>
@@ -546,12 +618,14 @@
pagesCached.innerHTML = '<span class="status-ok">' + uniqueUrls.length + ' pages</span>';
cachedCount.textContent = uniqueUrls.length;
// Group by category
// Group by category with better organization
const categories = {
'Main': [],
'Books': [],
'Topics': [],
'Main Pages': [],
'Bible Chapters': [],
'Verse Commentary': [],
'Interlinear': [],
'Stories': [],
'Topics': [],
'Study Guides': [],
'Reading Plans': [],
'Parables': [],
@@ -560,10 +634,16 @@
};
uniqueUrls.forEach(url => {
if (url === '/' || url === '/books' || url === '/offline' || url === '/search') {
categories['Main'].push(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['Books'].push(url);
categories['Bible Chapters'].push(url);
} else if (url.startsWith('/topics/')) {
categories['Topics'].push(url);
} else if (url.startsWith('/stories/')) {
@@ -581,21 +661,51 @@
}
});
// Render links
// 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="category">';
html += '<div class="category-title">' + category + ' (' + urls.length + ')</div>';
urls.forEach(url => {
const label = url === '/' ? 'Home' : url.replace(/^\//, '').replace(/-/g, ' ');
html += '<a href="' + url + '">' + label + '</a>';
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>';
@@ -1034,6 +1144,234 @@
});
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;
// Add click handlers for URL items
document.querySelectorAll('.url-item').forEach(item => {
item.addEventListener('click', function(e) {
// Let the link work normally
});
});
};
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) {
// Only handle if technical details is open
const details = document.querySelector('.debug-details');
if (!details || !details.open) return;
// Skip if in form elements
if (e.target.tagName === 'SELECT' || e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') return;
const categories = getUrlCategories();
if (categories.length === 0) return;
// Check if we're in the URL navigation zone
const cachedLinksSection = document.getElementById('cached-links');
const rect = cachedLinksSection.getBoundingClientRect();
const isVisible = rect.top < window.innerHeight && rect.bottom > 0;
if (!isVisible && selectedCategoryIdx < 0) return;
// u key to enter URL navigation mode
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 URL mode is active
if (urlNavMode || selectedCategoryIdx >= 0) {
e.preventDefault();
e.stopPropagation();
if (selectedUrlIdx >= 0 && isCategoryExpanded(selectedCategoryIdx)) {
// Navigate within URLs
const urls = getUrlsInCategory(categories[selectedCategoryIdx]);
if (selectedUrlIdx < urls.length - 1) {
selectUrlItem(selectedCategoryIdx, selectedUrlIdx + 1);
} else {
// Move to next category
if (selectedCategoryIdx < categories.length - 1) {
collapseCategory(selectedCategoryIdx);
selectCategory(selectedCategoryIdx + 1);
}
}
} else if (selectedCategoryIdx >= 0) {
// Navigate categories
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)) {
// Navigate within URLs
if (selectedUrlIdx > 0) {
selectUrlItem(selectedCategoryIdx, selectedUrlIdx - 1);
} else {
// Back to category selection
selectedUrlIdx = -1;
selectCategory(selectedCategoryIdx);
}
} else if (selectedCategoryIdx > 0) {
selectCategory(selectedCategoryIdx - 1);
}
}
} else if (e.key === 'Enter' || e.key === ' ') {
if (selectedUrlIdx >= 0) {
// Open selected URL
const urls = getUrlsInCategory(categories[selectedCategoryIdx]);
if (urls[selectedUrlIdx]) {
window.location.href = urls[selectedUrlIdx].href;
}
} else if (selectedCategoryIdx >= 0) {
e.preventDefault();
e.stopPropagation();
// Toggle category expansion
if (isCategoryExpanded(selectedCategoryIdx)) {
collapseCategory(selectedCategoryIdx);
} else {
expandCategory(selectedCategoryIdx);
// Select first URL in category
const urls = getUrlsInCategory(categories[selectedCategoryIdx]);
if (urls.length > 0) {
selectUrlItem(selectedCategoryIdx, 0);
}
}
}
} else if (e.key === 'Escape') {
if (selectedUrlIdx >= 0) {
// Back to category
e.preventDefault();
selectedUrlIdx = -1;
selectCategory(selectedCategoryIdx);
} else if (selectedCategoryIdx >= 0) {
// Exit URL nav mode
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) {
// Go back to category header
selectedUrlIdx = -1;
selectCategory(selectedCategoryIdx);
} else if (isCategoryExpanded(selectedCategoryIdx)) {
collapseCategory(selectedCategoryIdx);
}
}
}
}, true); // Use capture to get events before other handlers
})();
</script>
</body>