From e9afb127f8a01827c35e70404e3d3d26ff199c82 Mon Sep 17 00:00:00 2001 From: John O'Keefe Date: Thu, 29 Jan 2026 20:20:50 -0500 Subject: [PATCH] feat: add frontend search functionality with real-time results - Create search.js with debounced input (300ms) - Display results in dropdown modal with highlighted matches - Support keyboard navigation (arrows, Enter, Escape) - Show result count and 'no results' state - Highlight matching terms in results - Add autocomplete attribute to search input - Minimum 2 characters to trigger search --- templates/header.templ | 1 + web/static/search.js | 275 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 276 insertions(+) create mode 100644 web/static/search.js diff --git a/templates/header.templ b/templates/header.templ index 597e2f4..ce83f4b 100644 --- a/templates/header.templ +++ b/templates/header.templ @@ -19,6 +19,7 @@ templ Header(user User, currentPath string) { placeholder="Search your library..." class="w-full px-4 py-2 pl-10 border rounded-lg" style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);" + autocomplete="off" > diff --git a/web/static/search.js b/web/static/search.js new file mode 100644 index 0000000..4e09199 --- /dev/null +++ b/web/static/search.js @@ -0,0 +1,275 @@ +"use strict"; + +let searchTimeout = null; +const SEARCH_DEBOUNCE_MS = 300; +const SEARCH_MIN_CHARS = 2; + +function initializeSearch() { + const searchInput = document.getElementById('header-search'); + if (!searchInput) { + console.warn('Search input not found'); + return; + } + + searchInput.addEventListener('input', handleSearchInput); + searchInput.addEventListener('keydown', handleSearchKeydown); + searchInput.addEventListener('focus', () => { + if (searchInput.value.length >= SEARCH_MIN_CHARS) { + performSearch(searchInput.value); + } + }); + + document.addEventListener('click', (e) => { + const searchResults = document.getElementById('search-results'); + const searchInput = document.getElementById('header-search'); + + if (searchResults && !searchResults.contains(e.target) && e.target !== searchInput) { + hideSearchResults(); + } + }); +} + +function handleSearchInput(e) { + const query = e.target.value.trim(); + + clearTimeout(searchTimeout); + + if (query.length < SEARCH_MIN_CHARS) { + hideSearchResults(); + return; + } + + searchTimeout = setTimeout(() => { + performSearch(query); + }, SEARCH_DEBOUNCE_MS); +} + +function handleSearchKeydown(e) { + const searchResults = document.getElementById('search-results'); + if (!searchResults || searchResults.classList.contains('hidden')) { + return; + } + + const items = searchResults.querySelectorAll('.search-result-item'); + const currentIndex = parseInt(searchResults.dataset.selectedIndex || '-1'); + + if (e.key === 'ArrowDown') { + e.preventDefault(); + const nextIndex = Math.min(currentIndex + 1, items.length - 1); + selectSearchResult(items, nextIndex); + } else if (e.key === 'ArrowUp') { + e.preventDefault(); + const prevIndex = Math.max(currentIndex - 1, -1); + selectSearchResult(items, prevIndex); + } else if (e.key === 'Enter') { + e.preventDefault(); + if (currentIndex >= 0 && items[currentIndex]) { + items[currentIndex].querySelector('a').click(); + } + } else if (e.key === 'Escape') { + hideSearchResults(); + } +} + +function selectSearchResult(items, index) { + items.forEach((item, i) => { + if (i === index) { + item.classList.add('bg-opacity-80'); + } else { + item.classList.remove('bg-opacity-80'); + } + }); + + const searchResults = document.getElementById('search-results'); + searchResults.dataset.selectedIndex = index.toString(); +} + +function performSearch(query) { + const token = localStorage.getItem('token'); + if (!token) { + console.warn('No authentication token found'); + return; + } + + showSearchLoading(); + + fetch(`/api/media-items/search?q=${encodeURIComponent(query)}`, { + headers: { + 'Authorization': `Bearer ${token}`, + 'Content-Type': 'application/json' + } + }) + .then(response => { + if (response.status === 404) { + return { error: 'no results found', results: [] }; + } + return response.json(); + }) + .then(data => { + hideSearchLoading(); + + if (data.error && data.error === 'no results found') { + showNoResults(query); + } else if (Array.isArray(data) && data.length > 0) { + showSearchResults(data, query); + } else { + showNoResults(query); + } + }) + .catch(error => { + hideSearchLoading(); + console.error('Search error:', error); + showSearchError(); + }); +} + +function showSearchLoading() { + createSearchResultsContainer(); + const searchResults = document.getElementById('search-results'); + searchResults.innerHTML = ` +
+
+

Searching...

+
+ `; + searchResults.classList.remove('hidden'); +} + +function hideSearchLoading() { + +} + +function showSearchResults(results, query) { + createSearchResultsContainer(); + const searchResults = document.getElementById('search-results'); + searchResults.dataset.selectedIndex = '-1'; + + const libraryIconMap = { + 'ebooks': '📚', + 'comics': '📖', + 'manga': '🗾' + }; + + let html = ` +
+

+ ${results.length} result${results.length !== 1 ? 's' : ''} for "${escapeHtml(query)}" +

+
+
+ `; + + results.forEach((item, index) => { + const icon = libraryIconMap[item.library_type_name] || '📁'; + const titleHtml = highlightMatch(item.title, query); + const authorHtml = item.author ? highlightMatch(item.author, query) : ''; + + html += ` + + `; + }); + + html += ` +
+
+

+ Press ↑↓ to navigate, + Enter to select +

+
+ `; + + searchResults.innerHTML = html; + searchResults.classList.remove('hidden'); +} + +function showNoResults(query) { + createSearchResultsContainer(); + const searchResults = document.getElementById('search-results'); + + searchResults.innerHTML = ` +
+
🔍
+

No results found for "${escapeHtml(query)}"

+

Try different keywords

+
+ `; + searchResults.classList.remove('hidden'); +} + +function showSearchError() { + createSearchResultsContainer(); + const searchResults = document.getElementById('search-results'); + + searchResults.innerHTML = ` +
+
⚠️
+

Search error

+

Please try again

+
+ `; + searchResults.classList.remove('hidden'); +} + +function hideSearchResults() { + const searchResults = document.getElementById('search-results'); + if (searchResults) { + searchResults.classList.add('hidden'); + } +} + +function createSearchResultsContainer() { + let searchResults = document.getElementById('search-results'); + if (!searchResults) { + searchResults = document.createElement('div'); + searchResults.id = 'search-results'; + searchResults.className = 'hidden absolute z-50 w-full max-w-2xl mt-2 rounded-lg shadow-lg border'; + searchResults.style.cssText = 'background-color: var(--bg-secondary); border-color: var(--border)'; + + const searchInput = document.getElementById('header-search'); + const searchContainer = searchInput.closest('.relative'); + searchContainer.appendChild(searchResults); + } +} + +function highlightMatch(text, query) { + if (!text) return ''; + const escapedQuery = query.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + const regex = new RegExp(`(${escapedQuery})`, 'gi'); + return escapeHtml(text).replace(regex, '$1'); +} + +function escapeHtml(text) { + const div = document.createElement('div'); + div.textContent = text; + return div.innerHTML; +} + +function selectLibraryAndBook(libraryId, bookId) { + localStorage.setItem('selectedLibrary', libraryId); + localStorage.setItem('selectedBook', bookId); + hideSearchResults(); +} + +document.addEventListener('DOMContentLoaded', initializeSearch); + +window.selectLibraryAndBook = selectLibraryAndBook; \ No newline at end of file