mirror of
https://github.com/kennethreitz/kjvstudy.org.git
synced 2026-06-05 23:00:16 +00:00
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:
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user