From c76c129e43349550be0b147029b051783c95f0e6 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 4 Feb 2026 16:35:50 +0000 Subject: [PATCH 1/8] Initial plan From 484d820693835cfe828242fc260dca818837a078 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 4 Feb 2026 16:43:02 +0000 Subject: [PATCH 2/8] Add search functionality to clusters page for searching within tabs Co-authored-by: LukasWallrich <60155545+LukasWallrich@users.noreply.github.com> --- layouts/partials/custom_js.html | 6 + static/js/clusters-search.js | 359 ++++++++++++++++++++++++++++++++ 2 files changed, 365 insertions(+) create mode 100644 layouts/partials/custom_js.html create mode 100644 static/js/clusters-search.js diff --git a/layouts/partials/custom_js.html b/layouts/partials/custom_js.html new file mode 100644 index 00000000000..6483386262f --- /dev/null +++ b/layouts/partials/custom_js.html @@ -0,0 +1,6 @@ +{{/* Custom JavaScript for FORRT site */}} + +{{/* Include clusters search functionality on clusters page */}} +{{ if eq .RelPermalink "/clusters/" }} + +{{ end }} diff --git a/static/js/clusters-search.js b/static/js/clusters-search.js new file mode 100644 index 00000000000..205109829c4 --- /dev/null +++ b/static/js/clusters-search.js @@ -0,0 +1,359 @@ +/** + * Clusters Page Search Functionality + * Enables searching within Bootstrap tab content that would otherwise be hidden from Ctrl-F + */ +(function() { + 'use strict'; + + // Wait for DOM to be ready + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', init); + } else { + init(); + } + + function init() { + // Only run on clusters page + if (!window.location.pathname.includes('/clusters')) { + return; + } + + createSearchInterface(); + setupSearchHandlers(); + } + + function createSearchInterface() { + // Find the intro section to insert search box after it + const introSection = document.querySelector('.wg-blank'); + if (!introSection) return; + + // Create search container + const searchContainer = document.createElement('div'); + searchContainer.className = 'cluster-search-container'; + searchContainer.innerHTML = ` +
+
+
+
+ +
+ + +
+
+
+
+
+
+ `; + + // Insert after intro section + introSection.parentNode.insertBefore(searchContainer, introSection.nextSibling); + } + + function setupSearchHandlers() { + const searchInput = document.getElementById('clusterSearchInput'); + const searchBtn = document.getElementById('clusterSearchBtn'); + const clearBtn = document.getElementById('clusterClearBtn'); + const resultsDiv = document.getElementById('clusterSearchResults'); + + if (!searchInput || !searchBtn || !clearBtn) return; + + // Search on button click + searchBtn.addEventListener('click', performSearch); + + // Search on Enter key + searchInput.addEventListener('keypress', function(e) { + if (e.key === 'Enter') { + performSearch(); + } + }); + + // Clear search + clearBtn.addEventListener('click', function() { + searchInput.value = ''; + resultsDiv.innerHTML = ''; + clearBtn.style.display = 'none'; + removeAllHighlights(); + collapseAllTabs(); + }); + } + + function performSearch() { + const searchInput = document.getElementById('clusterSearchInput'); + const clearBtn = document.getElementById('clusterClearBtn'); + const resultsDiv = document.getElementById('clusterSearchResults'); + const query = searchInput.value.trim(); + + if (!query || query.length < 2) { + resultsDiv.innerHTML = '
Please enter at least 2 characters to search.
'; + return; + } + + // Remove previous highlights + removeAllHighlights(); + + // Search through all tab content + const results = searchAllTabs(query); + + // Display results + displayResults(results, query); + + // Show clear button + clearBtn.style.display = 'inline-block'; + } + + function searchAllTabs(query) { + const results = []; + const queryLower = query.toLowerCase(); + + // Find all cluster sections + const clusterSections = document.querySelectorAll('section[id^="cluster"]'); + + clusterSections.forEach(function(section) { + const clusterTitle = section.querySelector('h3, h2, .home-section-title'); + const clusterName = clusterTitle ? clusterTitle.textContent.trim() : 'Unknown Cluster'; + + // Find all tab panes in this cluster + const tabPanes = section.querySelectorAll('.tab-pane'); + + tabPanes.forEach(function(tabPane) { + const tabId = tabPane.id; + const content = tabPane.textContent || tabPane.innerText; + const contentLower = content.toLowerCase(); + + // Check if query is in content + if (contentLower.includes(queryLower)) { + // Count occurrences + const matches = countMatches(contentLower, queryLower); + + // Get tab label + const tabLink = section.querySelector(`a[href="#${tabId}"]`); + const tabLabel = tabLink ? tabLink.textContent.trim() : tabId; + + // Get a snippet of context + const snippet = getContextSnippet(content, query); + + results.push({ + cluster: clusterName, + tab: tabLabel, + tabId: tabId, + matches: matches, + snippet: snippet, + section: section, + tabPane: tabPane, + tabLink: tabLink + }); + } + }); + }); + + return results; + } + + function countMatches(text, query) { + const regex = new RegExp(query, 'gi'); + const matches = text.match(regex); + return matches ? matches.length : 0; + } + + function getContextSnippet(text, query) { + const queryLower = query.toLowerCase(); + const textLower = text.toLowerCase(); + const index = textLower.indexOf(queryLower); + + if (index === -1) return ''; + + const start = Math.max(0, index - 50); + const end = Math.min(text.length, index + query.length + 100); + let snippet = text.substring(start, end); + + if (start > 0) snippet = '...' + snippet; + if (end < text.length) snippet = snippet + '...'; + + // Highlight the query in snippet + const regex = new RegExp(`(${escapeRegExp(query)})`, 'gi'); + snippet = snippet.replace(regex, '$1'); + + return snippet; + } + + function escapeRegExp(string) { + return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + } + + function displayResults(results, query) { + const resultsDiv = document.getElementById('clusterSearchResults'); + + if (results.length === 0) { + resultsDiv.innerHTML = `
No results found for "${escapeHtml(query)}".
`; + return; + } + + let html = `
Found ${results.length} tab(s) containing "${escapeHtml(query)}" (${results.reduce((sum, r) => sum + r.matches, 0)} total matches). Click on a result to view it:
`; + html += '
'; + + results.forEach(function(result) { + html += ` + +
+
${escapeHtml(result.cluster)} → ${escapeHtml(result.tab)}
+ ${result.matches} match${result.matches > 1 ? 'es' : ''} +
+

${result.snippet}

+
+ `; + }); + + html += '
'; + resultsDiv.innerHTML = html; + + // Add click handlers to results + const resultLinks = resultsDiv.querySelectorAll('.cluster-search-result'); + resultLinks.forEach(function(link) { + link.addEventListener('click', function(e) { + e.preventDefault(); + const tabId = this.getAttribute('data-tab-id'); + const result = results.find(r => r.tabId === tabId); + if (result) { + activateTab(result); + highlightMatches(result.tabPane, query); + // Scroll to the section + result.section.scrollIntoView({ behavior: 'smooth', block: 'start' }); + } + }); + }); + + // Auto-expand first result + if (results.length > 0) { + activateTab(results[0]); + highlightMatches(results[0].tabPane, query); + } + } + + function escapeHtml(text) { + const div = document.createElement('div'); + div.textContent = text; + return div.innerHTML; + } + + function activateTab(result) { + // Collapse all tabs first + collapseAllTabs(); + + // Activate the target tab + if (result.tabLink) { + result.tabLink.click(); + } + } + + function collapseAllTabs() { + document.querySelectorAll('.tab-pane.show.active').forEach(function(pane) { + pane.classList.remove('show', 'active'); + }); + document.querySelectorAll('.nav-link.active').forEach(function(link) { + link.classList.remove('active'); + }); + } + + function highlightMatches(tabPane, query) { + if (!tabPane) return; + + // Remove existing highlights first + removeHighlightsInElement(tabPane); + + // Use mark.js-like approach to highlight matches + const regex = new RegExp(`(${escapeRegExp(query)})`, 'gi'); + + // Get all text nodes + const walker = document.createTreeWalker( + tabPane, + NodeFilter.SHOW_TEXT, + null, + false + ); + + const textNodes = []; + let node; + while (node = walker.nextNode()) { + // Skip if parent is already a mark + if (node.parentElement.tagName !== 'MARK') { + textNodes.push(node); + } + } + + textNodes.forEach(function(textNode) { + const text = textNode.textContent; + if (regex.test(text)) { + const span = document.createElement('span'); + span.innerHTML = text.replace(regex, '$1'); + textNode.parentNode.replaceChild(span, textNode); + } + }); + } + + function removeAllHighlights() { + document.querySelectorAll('.cluster-highlight').forEach(function(mark) { + const parent = mark.parentNode; + parent.replaceChild(document.createTextNode(mark.textContent), mark); + parent.normalize(); + }); + + // Also remove wrapper spans + document.querySelectorAll('.tab-pane span').forEach(function(span) { + if (span.childNodes.length === 1 && span.childNodes[0].nodeType === Node.TEXT_NODE) { + const text = span.textContent; + if (span.parentNode && !span.classList.length) { + span.parentNode.replaceChild(document.createTextNode(text), span); + } + } + }); + } + + function removeHighlightsInElement(element) { + element.querySelectorAll('.cluster-highlight').forEach(function(mark) { + const parent = mark.parentNode; + parent.replaceChild(document.createTextNode(mark.textContent), mark); + parent.normalize(); + }); + } + + // Add CSS for highlighting + const style = document.createElement('style'); + style.textContent = ` + .cluster-highlight { + background-color: #ffeb3b; + padding: 2px 0; + font-weight: bold; + } + + .cluster-search-container { + position: sticky; + top: 70px; + background: white; + z-index: 100; + padding: 20px 0 10px 0; + box-shadow: 0 2px 4px rgba(0,0,0,0.1); + } + + .cluster-search-result:hover { + cursor: pointer; + } + + .cluster-search-result mark { + background-color: #ffeb3b; + padding: 1px 2px; + } + `; + document.head.appendChild(style); + +})(); From 5abda06455983784fdb03693dfb7883ecdafccd8 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 4 Feb 2026 16:44:52 +0000 Subject: [PATCH 3/8] Fix code review issues: improve regex handling and DOM manipulation Co-authored-by: LukasWallrich <60155545+LukasWallrich@users.noreply.github.com> --- static/js/clusters-search.js | 25 ++++++++++--------------- 1 file changed, 10 insertions(+), 15 deletions(-) diff --git a/static/js/clusters-search.js b/static/js/clusters-search.js index 205109829c4..291be7ab283 100644 --- a/static/js/clusters-search.js +++ b/static/js/clusters-search.js @@ -284,7 +284,7 @@ const textNodes = []; let node; - while (node = walker.nextNode()) { + while ((node = walker.nextNode())) { // Skip if parent is already a mark if (node.parentElement.tagName !== 'MARK') { textNodes.push(node); @@ -293,10 +293,15 @@ textNodes.forEach(function(textNode) { const text = textNode.textContent; - if (regex.test(text)) { - const span = document.createElement('span'); - span.innerHTML = text.replace(regex, '$1'); - textNode.parentNode.replaceChild(span, textNode); + const testRegex = new RegExp(`(${escapeRegExp(query)})`, 'gi'); + if (testRegex.test(text)) { + const fragment = document.createDocumentFragment(); + const tempDiv = document.createElement('div'); + tempDiv.innerHTML = text.replace(regex, '$1'); + while (tempDiv.firstChild) { + fragment.appendChild(tempDiv.firstChild); + } + textNode.parentNode.replaceChild(fragment, textNode); } }); } @@ -307,16 +312,6 @@ parent.replaceChild(document.createTextNode(mark.textContent), mark); parent.normalize(); }); - - // Also remove wrapper spans - document.querySelectorAll('.tab-pane span').forEach(function(span) { - if (span.childNodes.length === 1 && span.childNodes[0].nodeType === Node.TEXT_NODE) { - const text = span.textContent; - if (span.parentNode && !span.classList.length) { - span.parentNode.replaceChild(document.createTextNode(text), span); - } - } - }); } function removeHighlightsInElement(element) { From 1066ec6d43c42ce521eeefb0a34d4aac2a7eff0d Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Wed, 18 Feb 2026 16:49:53 +0000 Subject: [PATCH 4/8] Redesign cluster search as collapsible sidebar (#650) * Initial plan * Redesign cluster search as collapsible left sidebar Co-authored-by: LukasWallrich <60155545+LukasWallrich@users.noreply.github.com> * Fix code review feedback: use alert-info for no results and prevent layout shift Co-authored-by: LukasWallrich <60155545+LukasWallrich@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: LukasWallrich <60155545+LukasWallrich@users.noreply.github.com> --- static/js/clusters-search.js | 296 +++++++++++++++++++++++++++++------ 1 file changed, 245 insertions(+), 51 deletions(-) diff --git a/static/js/clusters-search.js b/static/js/clusters-search.js index 291be7ab283..9dcf05146fa 100644 --- a/static/js/clusters-search.js +++ b/static/js/clusters-search.js @@ -23,40 +23,44 @@ } function createSearchInterface() { - // Find the intro section to insert search box after it - const introSection = document.querySelector('.wg-blank'); - if (!introSection) return; - - // Create search container - const searchContainer = document.createElement('div'); - searchContainer.className = 'cluster-search-container'; - searchContainer.innerHTML = ` -
-
-
-
- -
- - -
+ // Create sidebar container + const sidebar = document.createElement('div'); + sidebar.id = 'clusterSearchSidebar'; + sidebar.className = 'cluster-search-sidebar'; + sidebar.innerHTML = ` + +
+
+

Search Clusters

+ +
+
+
+ +
+
-
+ +
`; - // Insert after intro section - introSection.parentNode.insertBefore(searchContainer, introSection.nextSibling); + // Insert at beginning of body + document.body.insertBefore(sidebar, document.body.firstChild); } function setupSearchHandlers() { @@ -64,8 +68,31 @@ const searchBtn = document.getElementById('clusterSearchBtn'); const clearBtn = document.getElementById('clusterClearBtn'); const resultsDiv = document.getElementById('clusterSearchResults'); + const toggleBtn = document.getElementById('clusterSearchToggle'); + const closeBtn = document.getElementById('clusterSearchClose'); + const panel = document.getElementById('clusterSearchPanel'); + + if (!searchInput || !searchBtn || !clearBtn || !toggleBtn || !closeBtn || !panel) return; + + // Toggle sidebar + toggleBtn.addEventListener('click', function() { + panel.classList.toggle('open'); + if (panel.classList.contains('open')) { + searchInput.focus(); + } + }); - if (!searchInput || !searchBtn || !clearBtn) return; + // Close sidebar + closeBtn.addEventListener('click', function() { + panel.classList.remove('open'); + }); + + // Close on escape key + document.addEventListener('keydown', function(e) { + if (e.key === 'Escape' && panel.classList.contains('open')) { + panel.classList.remove('open'); + } + }); // Search on button click searchBtn.addEventListener('click', performSearch); @@ -198,19 +225,19 @@ return; } - let html = `
Found ${results.length} tab(s) containing "${escapeHtml(query)}" (${results.reduce((sum, r) => sum + r.matches, 0)} total matches). Click on a result to view it:
`; - html += '
'; + let html = `
Found ${results.length} tab(s) with ${results.reduce((sum, r) => sum + r.matches, 0)} matches
`; + html += '
'; results.forEach(function(result) { html += ` - -
-
${escapeHtml(result.cluster)} → ${escapeHtml(result.tab)}
- ${result.matches} match${result.matches > 1 ? 'es' : ''} +
+
+ ${escapeHtml(result.tab)} + ${result.matches}
-

${result.snippet}

-
+
${escapeHtml(result.cluster)}
+
${result.snippet}
+
`; }); @@ -218,10 +245,9 @@ resultsDiv.innerHTML = html; // Add click handlers to results - const resultLinks = resultsDiv.querySelectorAll('.cluster-search-result'); - resultLinks.forEach(function(link) { - link.addEventListener('click', function(e) { - e.preventDefault(); + const resultItems = resultsDiv.querySelectorAll('.search-result-item'); + resultItems.forEach(function(item) { + item.addEventListener('click', function() { const tabId = this.getAttribute('data-tab-id'); const result = results.find(r => r.tabId === tabId); if (result) { @@ -229,12 +255,16 @@ highlightMatches(result.tabPane, query); // Scroll to the section result.section.scrollIntoView({ behavior: 'smooth', block: 'start' }); + // Mark as active + resultItems.forEach(r => r.classList.remove('active')); + this.classList.add('active'); } }); }); // Auto-expand first result if (results.length > 0) { + resultItems[0].classList.add('active'); activateTab(results[0]); highlightMatches(results[0].tabPane, query); } @@ -322,31 +352,195 @@ }); } - // Add CSS for highlighting + // Add CSS for sidebar and highlighting const style = document.createElement('style'); style.textContent = ` + /* Highlight styling */ .cluster-highlight { background-color: #ffeb3b; padding: 2px 0; font-weight: bold; } - .cluster-search-container { - position: sticky; - top: 70px; + /* Sidebar toggle button */ + .cluster-search-toggle { + position: fixed; + left: 0; + top: 200px; + background: #007bff; + color: white; + border: none; + border-radius: 0 5px 5px 0; + padding: 12px 15px; + cursor: pointer; + z-index: 1000; + box-shadow: 2px 2px 5px rgba(0,0,0,0.2); + font-size: 14px; + transition: background 0.3s; + } + + .cluster-search-toggle:hover { + background: #0056b3; + } + + .cluster-search-toggle i { + margin-right: 5px; + } + + /* Sidebar panel */ + .cluster-search-panel { + position: fixed; + left: -350px; + top: 0; + width: 350px; + height: 100vh; background: white; - z-index: 100; - padding: 20px 0 10px 0; - box-shadow: 0 2px 4px rgba(0,0,0,0.1); + box-shadow: 2px 0 10px rgba(0,0,0,0.1); + z-index: 1001; + transition: left 0.3s ease; + display: flex; + flex-direction: column; + overflow: hidden; + } + + .cluster-search-panel.open { + left: 0; + } + + /* Sidebar header */ + .cluster-search-header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 15px 20px; + border-bottom: 1px solid #dee2e6; + background: #f8f9fa; + } + + .cluster-search-header h4 { + margin: 0; + font-size: 18px; + color: #333; } - .cluster-search-result:hover { + .cluster-search-close { + background: none; + border: none; + font-size: 20px; + color: #666; cursor: pointer; + padding: 5px; + line-height: 1; + } + + .cluster-search-close:hover { + color: #333; + } + + /* Sidebar body */ + .cluster-search-body { + padding: 20px; + flex: 1; + overflow-y: auto; + } + + /* Search results summary */ + .search-summary { + background: #e7f3ff; + padding: 10px; + border-radius: 5px; + margin-bottom: 15px; + font-size: 0.9rem; + color: #004085; } - .cluster-search-result mark { + /* Search results list */ + .search-results-list { + display: flex; + flex-direction: column; + gap: 10px; + } + + /* Individual search result */ + .search-result-item { + background: #f8f9fa; + border: 1px solid #dee2e6; + border-radius: 5px; + padding: 12px; + cursor: pointer; + transition: all 0.2s; + } + + .search-result-item:hover { + background: #e9ecef; + border-color: #007bff; + box-shadow: 0 2px 5px rgba(0,0,0,0.1); + } + + .search-result-item.active { + background: #e7f3ff; + border-color: #007bff; + box-shadow: inset 0 0 0 1px #007bff; + } + + .search-result-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 5px; + } + + .search-result-header strong { + font-size: 0.95rem; + color: #333; + flex: 1; + margin-right: 10px; + } + + .search-result-header .badge { + font-size: 0.75rem; + } + + .search-result-cluster { + font-size: 0.8rem; + color: #666; + margin-bottom: 8px; + } + + .search-result-snippet { + font-size: 0.8rem; + color: #555; + line-height: 1.4; + } + + .search-result-snippet mark { background-color: #ffeb3b; padding: 1px 2px; + font-weight: 600; + } + + /* Mobile responsive */ + @media (max-width: 768px) { + .cluster-search-panel { + width: 100%; + left: -100%; + } + + .cluster-search-panel.open { + left: 0; + } + + .cluster-search-toggle { + top: 150px; + font-size: 12px; + padding: 10px 12px; + } + } + + /* Alert styling in sidebar */ + .cluster-search-body .alert { + font-size: 0.85rem; + padding: 8px 12px; } `; document.head.appendChild(style); From 1d0a2a9a45e8e676512a8321f1f371c0476fcd74 Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Wed, 18 Feb 2026 17:10:30 +0000 Subject: [PATCH 5/8] [WIP] Fix search function layout on cluster page (#654) * Initial plan * Fix search panel visibility and scroll behavior Co-authored-by: LukasWallrich <60155545+LukasWallrich@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: LukasWallrich <60155545+LukasWallrich@users.noreply.github.com> Co-authored-by: Lukas Wallrich --- static/js/clusters-search.js | 24 ++++++++++++++++++++---- 1 file changed, 20 insertions(+), 4 deletions(-) diff --git a/static/js/clusters-search.js b/static/js/clusters-search.js index 9dcf05146fa..fb14e51c55d 100644 --- a/static/js/clusters-search.js +++ b/static/js/clusters-search.js @@ -253,8 +253,8 @@ if (result) { activateTab(result); highlightMatches(result.tabPane, query); - // Scroll to the section - result.section.scrollIntoView({ behavior: 'smooth', block: 'start' }); + // Scroll to the section with offset for navbar + scrollToElement(result.section); // Mark as active resultItems.forEach(r => r.classList.remove('active')); this.classList.add('active'); @@ -295,6 +295,22 @@ }); } + function scrollToElement(element) { + // Get navbar height to offset the scroll + const navbar = document.querySelector('.navbar-fixed-top, .fixed-top, nav.navbar'); + const navbarHeight = navbar ? navbar.offsetHeight : 70; + + // Calculate the position to scroll to + const elementPosition = element.getBoundingClientRect().top + window.pageYOffset; + const offsetPosition = elementPosition - navbarHeight - 20; // Extra 20px padding + + // Scroll to the calculated position + window.scrollTo({ + top: offsetPosition, + behavior: 'smooth' + }); + } + function highlightMatches(tabPane, query) { if (!tabPane) return; @@ -373,7 +389,7 @@ border-radius: 0 5px 5px 0; padding: 12px 15px; cursor: pointer; - z-index: 1000; + z-index: 1034; box-shadow: 2px 2px 5px rgba(0,0,0,0.2); font-size: 14px; transition: background 0.3s; @@ -396,7 +412,7 @@ height: 100vh; background: white; box-shadow: 2px 0 10px rgba(0,0,0,0.1); - z-index: 1001; + z-index: 1035; transition: left 0.3s ease; display: flex; flex-direction: column; From f45d03928085b3d718ea5f8a8db83b3c59931064 Mon Sep 17 00:00:00 2001 From: richarddushime Date: Wed, 18 Feb 2026 21:44:43 +0100 Subject: [PATCH 6/8] fix: improve clusters search panel UX - Auto-hide panel when clicking search result - Position panel below navbar to avoid hiding logo - Reduce panel width to 70% on mobile devices - Show only search icon on mobile toggle button --- static/js/clusters-search.js | 22 ++++++++++++++-------- 1 file changed, 14 insertions(+), 8 deletions(-) diff --git a/static/js/clusters-search.js b/static/js/clusters-search.js index fb14e51c55d..02e64aad788 100644 --- a/static/js/clusters-search.js +++ b/static/js/clusters-search.js @@ -29,7 +29,7 @@ sidebar.className = 'cluster-search-sidebar'; sidebar.innerHTML = `
@@ -258,6 +258,8 @@ // Mark as active resultItems.forEach(r => r.classList.remove('active')); this.classList.add('active'); + // Auto-hide the search panel + document.getElementById('clusterSearchPanel').classList.remove('open'); } }); }); @@ -407,9 +409,9 @@ .cluster-search-panel { position: fixed; left: -350px; - top: 0; + top: 70px; width: 350px; - height: 100vh; + height: calc(100vh - 70px); background: white; box-shadow: 2px 0 10px rgba(0,0,0,0.1); z-index: 1035; @@ -538,8 +540,8 @@ /* Mobile responsive */ @media (max-width: 768px) { .cluster-search-panel { - width: 100%; - left: -100%; + width: 70%; + left: -70%; } .cluster-search-panel.open { @@ -547,9 +549,13 @@ } .cluster-search-toggle { - top: 150px; - font-size: 12px; - padding: 10px 12px; + top: 170px; + font-size: 14px; + padding: 10px; + } + + .cluster-search-toggle-text { + display: none; } } From 09813fd51103a0bd6d81bec4ac0613167856ed7f Mon Sep 17 00:00:00 2001 From: Lukas Wallrich Date: Wed, 18 Mar 2026 16:12:00 +0000 Subject: [PATCH 7/8] Improve cluster search: searchable titles and scroll to match - Make cluster titles and tab labels searchable (e.g., searching "QRP" now finds the "QRPs" tab) - Search section description text outside of tabs - Scroll to the first highlighted match within a tab pane instead of the cluster section top - Handle section-level results (description/title matches) Co-Authored-By: Claude Opus 4.6 (1M context) --- static/js/clusters-search.js | 117 +++++++++++++++++++++++++---------- 1 file changed, 84 insertions(+), 33 deletions(-) diff --git a/static/js/clusters-search.js b/static/js/clusters-search.js index 02e64aad788..14bc6c39153 100644 --- a/static/js/clusters-search.js +++ b/static/js/clusters-search.js @@ -141,34 +141,63 @@ function searchAllTabs(query) { const results = []; const queryLower = query.toLowerCase(); - + // Find all cluster sections const clusterSections = document.querySelectorAll('section[id^="cluster"]'); - + clusterSections.forEach(function(section) { const clusterTitle = section.querySelector('h3, h2, .home-section-title'); const clusterName = clusterTitle ? clusterTitle.textContent.trim() : 'Unknown Cluster'; - + + // Search section content outside tabs (title, description) + var sectionOnlyText = getSectionNonTabText(section); + if (sectionOnlyText.toLowerCase().includes(queryLower)) { + results.push({ + cluster: clusterName, + tab: 'Description', + tabId: null, + matches: countMatches(sectionOnlyText.toLowerCase(), queryLower), + snippet: getContextSnippet(sectionOnlyText, query), + section: section, + tabPane: null, + tabLink: null + }); + } + // Find all tab panes in this cluster const tabPanes = section.querySelectorAll('.tab-pane'); - + tabPanes.forEach(function(tabPane) { const tabId = tabPane.id; const content = tabPane.textContent || tabPane.innerText; const contentLower = content.toLowerCase(); - - // Check if query is in content - if (contentLower.includes(queryLower)) { + + // Get tab label + const tabLink = section.querySelector(`a[href="#${tabId}"]`); + const tabLabel = tabLink ? tabLink.textContent.trim() : tabId; + const tabLabelLower = tabLabel.toLowerCase(); + + // Check if query is in content OR in tab label + const contentHasMatch = contentLower.includes(queryLower); + const labelHasMatch = tabLabelLower.includes(queryLower); + + if (contentHasMatch || labelHasMatch) { // Count occurrences - const matches = countMatches(contentLower, queryLower); - - // Get tab label - const tabLink = section.querySelector(`a[href="#${tabId}"]`); - const tabLabel = tabLink ? tabLink.textContent.trim() : tabId; - + var matches = countMatches(contentLower, queryLower); + if (labelHasMatch) { + matches += countMatches(tabLabelLower, queryLower); + } + // Get a snippet of context - const snippet = getContextSnippet(content, query); - + var snippet; + if (contentHasMatch) { + snippet = getContextSnippet(content, query); + } else { + // Match is only in the tab label + var regex = new RegExp('(' + escapeRegExp(query) + ')', 'gi'); + snippet = 'Tab: ' + tabLabel.replace(regex, '$1'); + } + results.push({ cluster: clusterName, tab: tabLabel, @@ -182,10 +211,16 @@ } }); }); - + return results; } + function getSectionNonTabText(section) { + var clone = section.cloneNode(true); + clone.querySelectorAll('.tab-pane, .nav-tabs, .nav').forEach(function(el) { el.remove(); }); + return clone.textContent.trim(); + } + function countMatches(text, query) { const regex = new RegExp(query, 'gi'); const matches = text.match(regex); @@ -219,18 +254,18 @@ function displayResults(results, query) { const resultsDiv = document.getElementById('clusterSearchResults'); - + if (results.length === 0) { resultsDiv.innerHTML = `
No results found for "${escapeHtml(query)}".
`; return; } - let html = `
Found ${results.length} tab(s) with ${results.reduce((sum, r) => sum + r.matches, 0)} matches
`; + let html = `
Found ${results.length} result(s) with ${results.reduce((sum, r) => sum + r.matches, 0)} matches
`; html += '
'; - - results.forEach(function(result) { + + results.forEach(function(result, index) { html += ` -
+
${escapeHtml(result.tab)} ${result.matches} @@ -240,7 +275,7 @@
`; }); - + html += '
'; resultsDiv.innerHTML = html; @@ -248,27 +283,43 @@ const resultItems = resultsDiv.querySelectorAll('.search-result-item'); resultItems.forEach(function(item) { item.addEventListener('click', function() { - const tabId = this.getAttribute('data-tab-id'); - const result = results.find(r => r.tabId === tabId); - if (result) { + var index = parseInt(this.getAttribute('data-result-index')); + var result = results[index]; + if (!result) return; + + removeAllHighlights(); + + if (result.tabPane) { + // Tab-level result: activate the tab and highlight activateTab(result); highlightMatches(result.tabPane, query); - // Scroll to the section with offset for navbar + // Scroll to first highlighted match within the tab pane + setTimeout(function() { + var firstHighlight = result.tabPane.querySelector('.cluster-highlight'); + scrollToElement(firstHighlight || result.tabPane); + }, 100); + } else { + // Section-level result (description/title): highlight in section + highlightMatches(result.section, query); scrollToElement(result.section); - // Mark as active - resultItems.forEach(r => r.classList.remove('active')); - this.classList.add('active'); - // Auto-hide the search panel - document.getElementById('clusterSearchPanel').classList.remove('open'); } + + // Mark as active + resultItems.forEach(function(r) { r.classList.remove('active'); }); + this.classList.add('active'); + // Auto-hide the search panel + document.getElementById('clusterSearchPanel').classList.remove('open'); }); }); // Auto-expand first result if (results.length > 0) { resultItems[0].classList.add('active'); - activateTab(results[0]); - highlightMatches(results[0].tabPane, query); + var first = results[0]; + if (first.tabPane) { + activateTab(first); + highlightMatches(first.tabPane, query); + } } } From 62bd0b3f0dc3c5558082cd141511a716959487fa Mon Sep 17 00:00:00 2001 From: Lukas Wallrich Date: Wed, 18 Mar 2026 16:44:56 +0000 Subject: [PATCH 8/8] Per-paragraph results, live search, fix cluster title display - Show one result per matching paragraph/reference instead of per tab - Filter out nested blocks (p inside li) to avoid duplicate results - Fix cluster name: use h1 (actual title) not h3 ("Description") - Live search as user types with 300ms debounce - Make tab labels searchable (e.g. "QRP" finds "QRPs" tab) - Scroll to the specific matched element, not the section top Co-Authored-By: Claude Opus 4.6 (1M context) --- static/js/clusters-search.js | 253 +++++++++++++++-------------------- 1 file changed, 107 insertions(+), 146 deletions(-) diff --git a/static/js/clusters-search.js b/static/js/clusters-search.js index 14bc6c39153..12ea57b95a7 100644 --- a/static/js/clusters-search.js +++ b/static/js/clusters-search.js @@ -40,9 +40,9 @@
-
@@ -104,6 +104,13 @@ } }); + // Live search as user types (debounced) + var debounceTimer; + searchInput.addEventListener('input', function() { + clearTimeout(debounceTimer); + debounceTimer = setTimeout(performSearch, 300); + }); + // Clear search clearBtn.addEventListener('click', function() { searchInput.value = ''; @@ -121,7 +128,9 @@ const query = searchInput.value.trim(); if (!query || query.length < 2) { - resultsDiv.innerHTML = '
Please enter at least 2 characters to search.
'; + resultsDiv.innerHTML = query.length > 0 ? '
Please enter at least 2 characters to search.
' : ''; + clearBtn.style.display = 'none'; + removeAllHighlights(); return; } @@ -146,108 +155,76 @@ const clusterSections = document.querySelectorAll('section[id^="cluster"]'); clusterSections.forEach(function(section) { - const clusterTitle = section.querySelector('h3, h2, .home-section-title'); + // Use h1 to get the actual cluster title (h3 is "Description" in the content) + const clusterTitle = section.querySelector('h1, .home-section-title'); const clusterName = clusterTitle ? clusterTitle.textContent.trim() : 'Unknown Cluster'; - // Search section content outside tabs (title, description) - var sectionOnlyText = getSectionNonTabText(section); - if (sectionOnlyText.toLowerCase().includes(queryLower)) { - results.push({ - cluster: clusterName, - tab: 'Description', - tabId: null, - matches: countMatches(sectionOnlyText.toLowerCase(), queryLower), - snippet: getContextSnippet(sectionOnlyText, query), - section: section, - tabPane: null, - tabLink: null - }); - } - // Find all tab panes in this cluster const tabPanes = section.querySelectorAll('.tab-pane'); tabPanes.forEach(function(tabPane) { const tabId = tabPane.id; - const content = tabPane.textContent || tabPane.innerText; - const contentLower = content.toLowerCase(); // Get tab label - const tabLink = section.querySelector(`a[href="#${tabId}"]`); + const tabLink = section.querySelector('a[href="#' + tabId + '"]'); const tabLabel = tabLink ? tabLink.textContent.trim() : tabId; const tabLabelLower = tabLabel.toLowerCase(); - // Check if query is in content OR in tab label - const contentHasMatch = contentLower.includes(queryLower); - const labelHasMatch = tabLabelLower.includes(queryLower); - - if (contentHasMatch || labelHasMatch) { - // Count occurrences - var matches = countMatches(contentLower, queryLower); - if (labelHasMatch) { - matches += countMatches(tabLabelLower, queryLower); - } - - // Get a snippet of context - var snippet; - if (contentHasMatch) { - snippet = getContextSnippet(content, query); - } else { - // Match is only in the tab label - var regex = new RegExp('(' + escapeRegExp(query) + ')', 'gi'); - snippet = 'Tab: ' + tabLabel.replace(regex, '$1'); - } - + // If the tab label itself matches, add a result for it + if (tabLabelLower.includes(queryLower)) { + var regex = new RegExp('(' + escapeRegExp(query) + ')', 'gi'); results.push({ cluster: clusterName, tab: tabLabel, tabId: tabId, - matches: matches, - snippet: snippet, + snippet: 'Tab: ' + tabLabel.replace(regex, '$1'), section: section, tabPane: tabPane, - tabLink: tabLink + tabLink: tabLink, + element: tabPane }); } + + // Search each block-level element individually for per-paragraph results + // Filter out nested blocks (e.g.

inside

  • ) to avoid duplicates + var allBlocks = tabPane.querySelectorAll('li, p, h2, h3, h4, h5, h6, blockquote'); + var blocks = Array.from(allBlocks).filter(function(block) { + var parent = block.parentElement; + while (parent && parent !== tabPane) { + if (parent.matches('li, p, blockquote')) return false; + parent = parent.parentElement; + } + return true; + }); + + blocks.forEach(function(block) { + var text = block.textContent || ''; + if (text.toLowerCase().includes(queryLower)) { + var snippetText = text.trim(); + if (snippetText.length > 200) { + snippetText = snippetText.substring(0, 200) + '...'; + } + var highlightRegex = new RegExp('(' + escapeRegExp(query) + ')', 'gi'); + var snippet = escapeHtml(snippetText).replace(highlightRegex, '$1'); + + results.push({ + cluster: clusterName, + tab: tabLabel, + tabId: tabId, + snippet: snippet, + section: section, + tabPane: tabPane, + tabLink: tabLink, + element: block + }); + } + }); }); }); return results; } - function getSectionNonTabText(section) { - var clone = section.cloneNode(true); - clone.querySelectorAll('.tab-pane, .nav-tabs, .nav').forEach(function(el) { el.remove(); }); - return clone.textContent.trim(); - } - - function countMatches(text, query) { - const regex = new RegExp(query, 'gi'); - const matches = text.match(regex); - return matches ? matches.length : 0; - } - - function getContextSnippet(text, query) { - const queryLower = query.toLowerCase(); - const textLower = text.toLowerCase(); - const index = textLower.indexOf(queryLower); - - if (index === -1) return ''; - - const start = Math.max(0, index - 50); - const end = Math.min(text.length, index + query.length + 100); - let snippet = text.substring(start, end); - - if (start > 0) snippet = '...' + snippet; - if (end < text.length) snippet = snippet + '...'; - - // Highlight the query in snippet - const regex = new RegExp(`(${escapeRegExp(query)})`, 'gi'); - snippet = snippet.replace(regex, '$1'); - - return snippet; - } - function escapeRegExp(string) { return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); } @@ -256,31 +233,26 @@ const resultsDiv = document.getElementById('clusterSearchResults'); if (results.length === 0) { - resultsDiv.innerHTML = `
    No results found for "${escapeHtml(query)}".
    `; + resultsDiv.innerHTML = '
    No results found for "' + escapeHtml(query) + '".
    '; return; } - let html = `
    Found ${results.length} result(s) with ${results.reduce((sum, r) => sum + r.matches, 0)} matches
    `; + var html = '
    Found ' + results.length + ' result(s)
    '; html += '
    '; results.forEach(function(result, index) { - html += ` -
    -
    - ${escapeHtml(result.tab)} - ${result.matches} -
    -
    ${escapeHtml(result.cluster)}
    -
    ${result.snippet}
    -
    - `; + html += '
    ' + + '
    ' + escapeHtml(result.tab) + '
    ' + + '
    ' + escapeHtml(result.cluster) + '
    ' + + '
    ' + result.snippet + '
    ' + + '
    '; }); html += '
    '; resultsDiv.innerHTML = html; // Add click handlers to results - const resultItems = resultsDiv.querySelectorAll('.search-result-item'); + var resultItems = resultsDiv.querySelectorAll('.search-result-item'); resultItems.forEach(function(item) { item.addEventListener('click', function() { var index = parseInt(this.getAttribute('data-result-index')); @@ -288,21 +260,13 @@ if (!result) return; removeAllHighlights(); + activateTab(result); + highlightMatches(result.tabPane, query); - if (result.tabPane) { - // Tab-level result: activate the tab and highlight - activateTab(result); - highlightMatches(result.tabPane, query); - // Scroll to first highlighted match within the tab pane - setTimeout(function() { - var firstHighlight = result.tabPane.querySelector('.cluster-highlight'); - scrollToElement(firstHighlight || result.tabPane); - }, 100); - } else { - // Section-level result (description/title): highlight in section - highlightMatches(result.section, query); - scrollToElement(result.section); - } + // Scroll to the specific element (paragraph/reference) + setTimeout(function() { + scrollToElement(result.element); + }, 100); // Mark as active resultItems.forEach(function(r) { r.classList.remove('active'); }); @@ -315,11 +279,8 @@ // Auto-expand first result if (results.length > 0) { resultItems[0].classList.add('active'); - var first = results[0]; - if (first.tabPane) { - activateTab(first); - highlightMatches(first.tabPane, query); - } + activateTab(results[0]); + highlightMatches(results[0].tabPane, query); } } @@ -332,7 +293,7 @@ function activateTab(result) { // Collapse all tabs first collapseAllTabs(); - + // Activate the target tab if (result.tabLink) { result.tabLink.click(); @@ -352,11 +313,11 @@ // Get navbar height to offset the scroll const navbar = document.querySelector('.navbar-fixed-top, .fixed-top, nav.navbar'); const navbarHeight = navbar ? navbar.offsetHeight : 70; - + // Calculate the position to scroll to const elementPosition = element.getBoundingClientRect().top + window.pageYOffset; const offsetPosition = elementPosition - navbarHeight - 20; // Extra 20px padding - + // Scroll to the calculated position window.scrollTo({ top: offsetPosition, @@ -366,13 +327,13 @@ function highlightMatches(tabPane, query) { if (!tabPane) return; - + // Remove existing highlights first removeHighlightsInElement(tabPane); - + // Use mark.js-like approach to highlight matches const regex = new RegExp(`(${escapeRegExp(query)})`, 'gi'); - + // Get all text nodes const walker = document.createTreeWalker( tabPane, @@ -380,7 +341,7 @@ null, false ); - + const textNodes = []; let node; while ((node = walker.nextNode())) { @@ -389,7 +350,7 @@ textNodes.push(node); } } - + textNodes.forEach(function(textNode) { const text = textNode.textContent; const testRegex = new RegExp(`(${escapeRegExp(query)})`, 'gi'); @@ -430,7 +391,7 @@ padding: 2px 0; font-weight: bold; } - + /* Sidebar toggle button */ .cluster-search-toggle { position: fixed; @@ -447,15 +408,15 @@ font-size: 14px; transition: background 0.3s; } - + .cluster-search-toggle:hover { background: #0056b3; } - + .cluster-search-toggle i { margin-right: 5px; } - + /* Sidebar panel */ .cluster-search-panel { position: fixed; @@ -471,11 +432,11 @@ flex-direction: column; overflow: hidden; } - + .cluster-search-panel.open { left: 0; } - + /* Sidebar header */ .cluster-search-header { display: flex; @@ -485,13 +446,13 @@ border-bottom: 1px solid #dee2e6; background: #f8f9fa; } - + .cluster-search-header h4 { margin: 0; font-size: 18px; color: #333; } - + .cluster-search-close { background: none; border: none; @@ -501,18 +462,18 @@ padding: 5px; line-height: 1; } - + .cluster-search-close:hover { color: #333; } - + /* Sidebar body */ .cluster-search-body { padding: 20px; flex: 1; overflow-y: auto; } - + /* Search results summary */ .search-summary { background: #e7f3ff; @@ -522,14 +483,14 @@ font-size: 0.9rem; color: #004085; } - + /* Search results list */ .search-results-list { display: flex; flex-direction: column; gap: 10px; } - + /* Individual search result */ .search-result-item { background: #f8f9fa; @@ -539,77 +500,77 @@ cursor: pointer; transition: all 0.2s; } - + .search-result-item:hover { background: #e9ecef; border-color: #007bff; box-shadow: 0 2px 5px rgba(0,0,0,0.1); } - + .search-result-item.active { background: #e7f3ff; border-color: #007bff; box-shadow: inset 0 0 0 1px #007bff; } - + .search-result-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 5px; } - + .search-result-header strong { font-size: 0.95rem; color: #333; flex: 1; margin-right: 10px; } - + .search-result-header .badge { font-size: 0.75rem; } - + .search-result-cluster { font-size: 0.8rem; color: #666; margin-bottom: 8px; } - + .search-result-snippet { font-size: 0.8rem; color: #555; line-height: 1.4; } - + .search-result-snippet mark { background-color: #ffeb3b; padding: 1px 2px; font-weight: 600; } - + /* Mobile responsive */ @media (max-width: 768px) { .cluster-search-panel { width: 70%; left: -70%; } - + .cluster-search-panel.open { left: 0; } - + .cluster-search-toggle { top: 170px; font-size: 14px; padding: 10px; } - + .cluster-search-toggle-text { display: none; } } - + /* Alert styling in sidebar */ .cluster-search-body .alert { font-size: 0.85rem;