diff --git a/build/build.js b/build/build.js index 94ef81a..2bd0892 100644 --- a/build/build.js +++ b/build/build.js @@ -36,7 +36,7 @@ const config = { // File processing order (dependency-aware) cssOrder: ['base.css', 'layout.css', 'components.css', 'themes.css', 'prism.css'], - jsOrder: ['prism-core.js', 'navigation.js', 'progress.js', 'code-blocks.js', 'quiz.js', 'main.js'], + jsOrder: ['prism-core.js', 'navigation.js', 'progress.js', 'code-blocks.js', 'quiz.js', 'search.js', 'main.js'], // Template placeholders placeholders: { diff --git a/src/index.html b/src/index.html index f1e1bba..e790fa3 100644 --- a/src/index.html +++ b/src/index.html @@ -19,6 +19,32 @@

RESTful API Design

7-Day Learning Module

+ + +
+
+ + + + + + +
+ +
+ diff --git a/src/scripts/main.js b/src/scripts/main.js index f796887..450907b 100644 --- a/src/scripts/main.js +++ b/src/scripts/main.js @@ -32,7 +32,7 @@ function loadTheme() { /** * Initialize the application when DOM is ready - * Sets up theme, loads progress, renders navigation, and initializes code blocks + * Sets up theme, loads progress, renders navigation, initializes code blocks, and search */ document.addEventListener('DOMContentLoaded', () => { loadTheme(); @@ -40,4 +40,5 @@ document.addEventListener('DOMContentLoaded', () => { renderNav(); updateProgress(); initializeCodeBlocks(); + initializeSearch(); }); diff --git a/src/scripts/search.js b/src/scripts/search.js new file mode 100644 index 0000000..b704fa0 --- /dev/null +++ b/src/scripts/search.js @@ -0,0 +1,386 @@ +// ============================================================ +// SEARCH MODULE +// Provides search functionality across all day content +// Features: Real-time search, highlighting, result navigation +// ============================================================ + +let searchIndex = null; +let currentResults = []; +let currentResultIndex = -1; + +/** + * Initialize search functionality + * Builds search index from all day content + */ +function initializeSearch() { + buildSearchIndex(); + + const searchInput = document.getElementById('search-input'); + const searchClear = document.getElementById('search-clear'); + + if (searchInput) { + // Real-time search as user types + searchInput.addEventListener('input', debounce(handleSearch, 300)); + + // Handle Enter key to navigate results + searchInput.addEventListener('keydown', (e) => { + if (e.key === 'Enter') { + e.preventDefault(); + navigateToNextResult(); + } + }); + } + + if (searchClear) { + searchClear.addEventListener('click', clearSearch); + } +} + +/** + * Build search index from all day content + * Creates a searchable database of all text content with location info + */ +function buildSearchIndex() { + searchIndex = []; + + const days = document.querySelectorAll('.day-content'); + days.forEach(dayElement => { + const dayId = dayElement.id; + const dayNum = parseInt(dayId.replace('day', '')); + const dayTitle = dayElement.querySelector('h2')?.textContent || `Day ${dayNum}`; + + // Index all headings and paragraphs + const elements = dayElement.querySelectorAll('h2, h3, h4, p, li, td, th, code'); + elements.forEach((element, index) => { + const text = element.textContent.trim(); + if (text.length > 0) { + searchIndex.push({ + dayNum, + dayId, + dayTitle, + text, + element, + tagName: element.tagName.toLowerCase(), + // Get section context (nearest heading) + section: getParentHeading(element) + }); + } + }); + }); +} + +/** + * Get the nearest parent heading for context + * @param {HTMLElement} element - The element to find context for + * @returns {string} The heading text or empty string + */ +function getParentHeading(element) { + let current = element; + while (current && current.tagName !== 'BODY') { + if (/^H[2-4]$/.test(current.tagName)) { + return current.textContent.trim(); + } + current = current.previousElementSibling || current.parentElement; + } + return ''; +} + +/** + * Handle search input + * @param {Event} e - Input event + */ +function handleSearch(e) { + const query = e.target.value.trim(); + const resultsContainer = document.getElementById('search-results'); + const searchClear = document.getElementById('search-clear'); + + // Show/hide clear button + if (searchClear) { + searchClear.style.display = query ? 'block' : 'none'; + } + + // Clear previous results + clearHighlights(); + currentResults = []; + currentResultIndex = -1; + + if (query.length < 2) { + if (resultsContainer) { + resultsContainer.style.display = 'none'; + resultsContainer.innerHTML = ''; + } + return; + } + + // Perform search + const results = performSearch(query); + currentResults = results; + + // Display results + displaySearchResults(results, query); +} + +/** + * Perform search against the index + * @param {string} query - Search query + * @returns {Array} Array of search results + */ +function performSearch(query) { + const lowerQuery = query.toLowerCase(); + const results = []; + const seenSections = new Set(); + + searchIndex.forEach(entry => { + const lowerText = entry.text.toLowerCase(); + if (lowerText.includes(lowerQuery)) { + // Create unique key for deduplication + const key = `${entry.dayNum}-${entry.section}`; + + // Limit results per section to avoid overwhelming UI + if (!seenSections.has(key) || seenSections.size < 50) { + results.push({ + ...entry, + // Calculate relevance score + score: calculateRelevance(entry.text, query, entry.tagName) + }); + seenSections.add(key); + } + } + }); + + // Sort by relevance (score) and day number + results.sort((a, b) => { + if (b.score !== a.score) return b.score - a.score; + return a.dayNum - b.dayNum; + }); + + return results.slice(0, 30); // Limit to top 30 results +} + +/** + * Calculate relevance score for search results + * @param {string} text - Text to score + * @param {string} query - Search query + * @param {string} tagName - HTML tag name + * @returns {number} Relevance score + */ +function calculateRelevance(text, query, tagName) { + const lowerText = text.toLowerCase(); + const lowerQuery = query.toLowerCase(); + let score = 0; + + // Exact match gets highest score + if (lowerText === lowerQuery) score += 100; + + // Match at start of text + if (lowerText.startsWith(lowerQuery)) score += 50; + + // Match in headings is more relevant + if (tagName === 'h2') score += 30; + else if (tagName === 'h3') score += 20; + else if (tagName === 'h4') score += 10; + + // Word boundary match + if (new RegExp(`\\b${escapeRegex(lowerQuery)}\\b`).test(lowerText)) { + score += 25; + } + + // Case-sensitive exact match bonus + if (text.includes(query)) score += 15; + + // Shorter text with match is more relevant + if (text.length < 100) score += 5; + + return score; +} + +/** + * Display search results in the sidebar + * @param {Array} results - Search results to display + * @param {string} query - Original search query + */ +function displaySearchResults(results, query) { + const resultsContainer = document.getElementById('search-results'); + if (!resultsContainer) return; + + if (results.length === 0) { + resultsContainer.style.display = 'block'; + resultsContainer.innerHTML = ` +
+ No results found for "${escapeHtml(query)}" +
+ `; + return; + } + + resultsContainer.style.display = 'block'; + resultsContainer.innerHTML = ` +
+ ${results.length} result${results.length === 1 ? '' : 's'} found +
+ ${results.map((result, index) => ` +
+
Day ${result.dayNum}
+
${escapeHtml(result.section || result.dayTitle)}
+
${highlightText(result.text, query, 100)}
+
+ `).join('')} + `; +} + +/** + * Navigate to a specific search result + * @param {number} index - Index of result to navigate to + */ +function navigateToResult(index) { + if (index < 0 || index >= currentResults.length) return; + + const result = currentResults[index]; + currentResultIndex = index; + + // Switch to the day containing the result + showDay(result.dayNum); + + // Scroll to and highlight the element + setTimeout(() => { + clearHighlights(); + highlightElement(result.element); + result.element.scrollIntoView({ behavior: 'smooth', block: 'center' }); + + // Update active result in sidebar + document.querySelectorAll('.search-result-item').forEach((item, i) => { + item.classList.toggle('active', i === index); + }); + }, 100); +} + +/** + * Navigate to next search result (called on Enter key) + */ +function navigateToNextResult() { + if (currentResults.length === 0) return; + + currentResultIndex = (currentResultIndex + 1) % currentResults.length; + navigateToResult(currentResultIndex); +} + +/** + * Highlight a specific element in the content + * @param {HTMLElement} element - Element to highlight + */ +function highlightElement(element) { + element.classList.add('search-highlight'); + + // Remove highlight after 3 seconds + setTimeout(() => { + element.classList.remove('search-highlight'); + }, 3000); +} + +/** + * Clear all search highlights + */ +function clearHighlights() { + document.querySelectorAll('.search-highlight').forEach(el => { + el.classList.remove('search-highlight'); + }); +} + +/** + * Clear search input and results + */ +function clearSearch() { + const searchInput = document.getElementById('search-input'); + const resultsContainer = document.getElementById('search-results'); + const searchClear = document.getElementById('search-clear'); + + if (searchInput) { + searchInput.value = ''; + searchInput.focus(); + } + + if (resultsContainer) { + resultsContainer.style.display = 'none'; + resultsContainer.innerHTML = ''; + } + + if (searchClear) { + searchClear.style.display = 'none'; + } + + clearHighlights(); + currentResults = []; + currentResultIndex = -1; +} + +/** + * Highlight query text within a longer string + * @param {string} text - Text to highlight within + * @param {string} query - Query to highlight + * @param {number} maxLength - Maximum length of preview + * @returns {string} HTML with highlighted text + */ +function highlightText(text, query, maxLength = 100) { + const lowerText = text.toLowerCase(); + const lowerQuery = query.toLowerCase(); + const index = lowerText.indexOf(lowerQuery); + + if (index === -1) { + return escapeHtml(text.substring(0, maxLength)) + (text.length > maxLength ? '...' : ''); + } + + // Get context around the match + const start = Math.max(0, index - 30); + const end = Math.min(text.length, index + query.length + 70); + + let preview = text.substring(start, end); + if (start > 0) preview = '...' + preview; + if (end < text.length) preview = preview + '...'; + + // Highlight the query + const regex = new RegExp(`(${escapeRegex(query)})`, 'gi'); + preview = escapeHtml(preview).replace( + new RegExp(`(${escapeRegex(escapeHtml(query))})`, 'gi'), + '$1' + ); + + return preview; +} + +/** + * Escape HTML special characters + * @param {string} text - Text to escape + * @returns {string} Escaped text + */ +function escapeHtml(text) { + const div = document.createElement('div'); + div.textContent = text; + return div.innerHTML; +} + +/** + * Escape regex special characters + * @param {string} str - String to escape + * @returns {string} Escaped string + */ +function escapeRegex(str) { + return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} + +/** + * Debounce function to limit execution rate + * @param {Function} func - Function to debounce + * @param {number} wait - Wait time in milliseconds + * @returns {Function} Debounced function + */ +function debounce(func, wait) { + let timeout; + return function executedFunction(...args) { + const later = () => { + clearTimeout(timeout); + func.apply(this, args); + }; + clearTimeout(timeout); + timeout = setTimeout(later, wait); + }; +} diff --git a/src/styles/components.css b/src/styles/components.css index 2460475..bb1f7f2 100644 --- a/src/styles/components.css +++ b/src/styles/components.css @@ -47,6 +47,212 @@ color: var(--warning); } +/* ---------------------------------------- + Search Component + ---------------------------------------- */ +.search-container { + margin-bottom: 20px; +} + +.search-input-wrapper { + position: relative; + display: flex; + align-items: center; +} + +.search-icon { + position: absolute; + left: 14px; + color: var(--text-secondary); + pointer-events: none; +} + +.search-input { + width: 100%; + padding: 12px 40px 12px 40px; + border: 2px solid var(--border); + border-radius: 10px; + background-color: var(--bg-primary); + color: var(--text-primary); + font-size: 14px; + font-family: inherit; + transition: all 0.2s; +} + +.search-input:focus { + outline: none; + border-color: var(--accent); + background-color: var(--bg-secondary); +} + +.search-input::placeholder { + color: var(--text-secondary); +} + +.search-clear { + position: absolute; + right: 10px; + background: none; + border: none; + color: var(--text-secondary); + cursor: pointer; + padding: 4px; + border-radius: 4px; + transition: all 0.2s; + display: flex; + align-items: center; + justify-content: center; +} + +.search-clear:hover { + background-color: var(--bg-tertiary); + color: var(--text-primary); +} + +/* Search Results */ +.search-results { + margin-top: 12px; + max-height: 400px; + overflow-y: auto; + background-color: var(--bg-primary); + border: 1px solid var(--border); + border-radius: 10px; + padding: 10px; +} + +.search-results-header { + padding: 10px 12px; + font-size: 13px; + color: var(--text-secondary); + border-bottom: 1px solid var(--border); + margin-bottom: 8px; +} + +.search-results-header strong { + color: var(--accent); + font-weight: 600; +} + +.search-result-item { + padding: 12px; + margin-bottom: 6px; + background-color: var(--bg-secondary); + border-radius: 8px; + cursor: pointer; + transition: all 0.2s; + border-left: 3px solid transparent; +} + +.search-result-item:hover { + background-color: var(--accent-light); + border-left-color: var(--accent); + transform: translateX(2px); +} + +.search-result-item.active { + background-color: var(--accent); + color: white; + border-left-color: var(--warning); +} + +.search-result-day { + font-size: 11px; + font-weight: 700; + color: var(--accent); + text-transform: uppercase; + margin-bottom: 4px; +} + +.search-result-item.active .search-result-day { + color: var(--warning); +} + +.search-result-section { + font-size: 13px; + font-weight: 600; + margin-bottom: 6px; + color: var(--text-primary); +} + +.search-result-item.active .search-result-section { + color: white; +} + +.search-result-preview { + font-size: 12px; + color: var(--text-secondary); + line-height: 1.4; + overflow: hidden; + text-overflow: ellipsis; + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; +} + +.search-result-item.active .search-result-preview { + color: rgba(255, 255, 255, 0.9); +} + +.search-result-preview mark { + background-color: var(--warning); + color: var(--text-primary); + padding: 2px 4px; + border-radius: 3px; + font-weight: 600; +} + +.search-result-item.active .search-result-preview mark { + background-color: white; + color: var(--accent); +} + +.search-no-results { + padding: 20px; + text-align: center; + color: var(--text-secondary); + font-size: 14px; +} + +/* Search Highlight in Content */ +.search-highlight { + background-color: var(--warning); + padding: 4px 6px; + border-radius: 4px; + animation: highlight-pulse 0.5s ease-in-out; + box-shadow: 0 0 0 4px var(--warning-light); +} + +@keyframes highlight-pulse { + 0% { + box-shadow: 0 0 0 0 var(--warning-light); + } + 50% { + box-shadow: 0 0 0 8px var(--warning-light); + } + 100% { + box-shadow: 0 0 0 4px var(--warning-light); + } +} + +/* Scrollbar for search results */ +.search-results::-webkit-scrollbar { + width: 6px; +} + +.search-results::-webkit-scrollbar-track { + background: var(--bg-secondary); + border-radius: 10px; +} + +.search-results::-webkit-scrollbar-thumb { + background: var(--accent); + border-radius: 10px; +} + +.search-results::-webkit-scrollbar-thumb:hover { + background: var(--accent-hover); +} + /* ---------------------------------------- Buttons ---------------------------------------- */