mirror of
https://github.com/NotAShelf/nvf.git
synced 2025-12-13 15:41:03 +00:00
786 lines
24 KiB
JavaScript
786 lines
24 KiB
JavaScript
if (!window.searchNamespace) window.searchNamespace = {};
|
|
|
|
class SearchEngine {
|
|
constructor() {
|
|
this.documents = [];
|
|
this.tokenMap = new Map();
|
|
this.isLoaded = false;
|
|
this.loadError = false;
|
|
this.useWebWorker = typeof Worker !== 'undefined' && searchWorker !== null;
|
|
this.fullDocuments = null; // for lazy loading
|
|
this.rootPath = window.searchNamespace?.rootPath || '';
|
|
}
|
|
|
|
// Load search data from JSON
|
|
async loadData() {
|
|
if (this.isLoaded && !this.loadError) return;
|
|
|
|
// Clear previous error state on retry
|
|
this.loadError = false;
|
|
|
|
try {
|
|
// Load JSON data, try multiple possible paths
|
|
// FIXME: There is only one possible path for now, and this search data is guaranteed
|
|
// to generate at this location, but we'll want to extend this in the future.
|
|
const possiblePaths = ["/assets/search-data.json"];
|
|
|
|
let response = null;
|
|
let usedPath = "";
|
|
|
|
for (const path of possiblePaths) {
|
|
try {
|
|
const testResponse = await fetch(path);
|
|
if (testResponse.ok) {
|
|
response = testResponse;
|
|
usedPath = path;
|
|
break;
|
|
}
|
|
} catch (e) {
|
|
// Continue to next path
|
|
}
|
|
}
|
|
|
|
if (!response) {
|
|
throw new Error("Search data file not found at any expected location");
|
|
}
|
|
|
|
console.log(`Loading search data from: ${usedPath}`);
|
|
if (!response.ok) {
|
|
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
|
}
|
|
|
|
// Use optimized JSON parsing for large files
|
|
const documents = await this.parseLargeJSON(response);
|
|
if (!Array.isArray(documents)) {
|
|
throw new Error("Invalid search data format");
|
|
}
|
|
|
|
this.initializeFromDocuments(documents);
|
|
this.isLoaded = true;
|
|
console.log(`Loaded ${documents.length} documents for search`);
|
|
} catch (error) {
|
|
console.error("Error loading search data:", error);
|
|
this.documents = [];
|
|
this.tokenMap.clear();
|
|
this.loadError = true;
|
|
}
|
|
}
|
|
|
|
// Initialize from documents array
|
|
async initializeFromDocuments(documents) {
|
|
if (!Array.isArray(documents)) {
|
|
console.error("Invalid documents format:", typeof documents);
|
|
this.documents = [];
|
|
} else {
|
|
this.documents = documents;
|
|
console.log(`Initialized with ${documents.length} documents`);
|
|
}
|
|
try {
|
|
await this.buildTokenMap();
|
|
} catch (error) {
|
|
console.error("Error building token map:", error);
|
|
}
|
|
}
|
|
|
|
// Initialize from search index structure
|
|
initializeIndex(indexData) {
|
|
this.documents = indexData.documents || [];
|
|
this.tokenMap = new Map(Object.entries(indexData.tokenMap || {}));
|
|
}
|
|
|
|
// Build token map
|
|
// This is helpful for faster searching with progressive loading
|
|
buildTokenMap() {
|
|
return new Promise((resolve, reject) => {
|
|
this.tokenMap.clear();
|
|
|
|
if (!Array.isArray(this.documents)) {
|
|
console.error("No documents to build token map");
|
|
resolve();
|
|
return;
|
|
}
|
|
|
|
const totalDocs = this.documents.length;
|
|
let processedDocs = 0;
|
|
|
|
try {
|
|
// Process in chunks to avoid blocking UI
|
|
const processChunk = (startIndex, chunkSize) => {
|
|
try {
|
|
const endIndex = Math.min(startIndex + chunkSize, totalDocs);
|
|
|
|
for (let i = startIndex; i < endIndex; i++) {
|
|
const doc = this.documents[i];
|
|
if (!doc || typeof doc.title !== 'string' || typeof doc.content !== 'string') {
|
|
console.warn(`Invalid document at index ${i}:`, doc);
|
|
continue;
|
|
}
|
|
|
|
const tokens = this.tokenize(doc.title + " " + doc.content);
|
|
tokens.forEach(token => {
|
|
if (!this.tokenMap.has(token)) {
|
|
this.tokenMap.set(token, []);
|
|
}
|
|
this.tokenMap.get(token).push(i);
|
|
});
|
|
|
|
processedDocs++;
|
|
}
|
|
|
|
// Update progress and yield control
|
|
if (endIndex < totalDocs) {
|
|
setTimeout(() => processChunk(endIndex, chunkSize), 0);
|
|
} else {
|
|
console.log(`Built token map with ${this.tokenMap.size} unique tokens from ${processedDocs} documents`);
|
|
resolve();
|
|
}
|
|
} catch (error) {
|
|
reject(error);
|
|
}
|
|
};
|
|
|
|
// Start processing with small chunks
|
|
processChunk(0, 100);
|
|
} catch (error) {
|
|
reject(error);
|
|
}
|
|
});
|
|
}
|
|
|
|
// Tokenize text into searchable terms
|
|
tokenize(text) {
|
|
const tokens = new Set();
|
|
const words = text.toLowerCase().match(/\b[a-zA-Z0-9_-]+\b/g) || [];
|
|
|
|
words.forEach(word => {
|
|
if (word.length > 2) {
|
|
tokens.add(word);
|
|
}
|
|
});
|
|
|
|
return Array.from(tokens);
|
|
}
|
|
|
|
// Advanced search with ranking
|
|
async search(query, limit = 10) {
|
|
if (!query.trim()) return [];
|
|
|
|
// Wait for data to be loaded
|
|
if (!this.isLoaded) {
|
|
await this.loadData();
|
|
}
|
|
|
|
if (!this.isLoaded || this.documents.length === 0) {
|
|
console.log("Search data not available");
|
|
return [];
|
|
}
|
|
|
|
const searchTerms = this.tokenize(query);
|
|
if (searchTerms.length === 0) return [];
|
|
|
|
// Fallback to basic search if token map is empty
|
|
if (this.tokenMap.size === 0) {
|
|
return this.fallbackSearch(query, limit);
|
|
}
|
|
|
|
// Use Web Worker for large datasets to avoid blocking UI
|
|
if (this.useWebWorker && this.documents.length > 1000) {
|
|
return await this.searchWithWorker(query, limit);
|
|
}
|
|
|
|
// For very large datasets, implement lazy loading with candidate docIds
|
|
if (this.documents.length > 10000) {
|
|
const candidateDocIds = new Set();
|
|
searchTerms.forEach(term => {
|
|
const docIds = this.tokenMap.get(term) || [];
|
|
docIds.forEach(id => candidateDocIds.add(id));
|
|
});
|
|
const docIds = Array.from(candidateDocIds);
|
|
return await this.lazyLoadDocuments(docIds, limit);
|
|
}
|
|
|
|
const docScores = new Map();
|
|
|
|
searchTerms.forEach(term => {
|
|
const docIds = this.tokenMap.get(term) || [];
|
|
docIds.forEach(docId => {
|
|
const doc = this.documents[docId];
|
|
if (!doc) return;
|
|
|
|
const currentScore = docScores.get(docId) || 0;
|
|
|
|
// Calculate score based on term position and importance
|
|
let score = 1;
|
|
|
|
// Title matches get higher score
|
|
if (doc.title.toLowerCase().includes(term)) {
|
|
score += 10;
|
|
// Exact title match gets even higher score
|
|
if (doc.title.toLowerCase() === term) {
|
|
score += 20;
|
|
}
|
|
}
|
|
|
|
// Content matches
|
|
if (doc.content.toLowerCase().includes(term)) {
|
|
score += 2;
|
|
}
|
|
|
|
// Boost for multiple term matches
|
|
docScores.set(docId, currentScore + score);
|
|
});
|
|
});
|
|
|
|
// Sort by score and return top results
|
|
const scoredResults = Array.from(docScores.entries())
|
|
.sort((a, b) => b[1] - a[1])
|
|
.slice(0, limit);
|
|
|
|
return scoredResults
|
|
.map(([docId, score]) => ({
|
|
...this.documents[docId],
|
|
score
|
|
}));
|
|
}
|
|
|
|
// Generate search preview with highlighting
|
|
generatePreview(content, query, maxLength = 150) {
|
|
const lowerContent = content.toLowerCase();
|
|
|
|
let bestIndex = -1;
|
|
let bestScore = 0;
|
|
let bestMatch = "";
|
|
|
|
// Find the best match position
|
|
const queryWords = this.tokenize(query);
|
|
queryWords.forEach(word => {
|
|
const index = lowerContent.indexOf(word);
|
|
if (index !== -1) {
|
|
const score = word.length; // longer words get higher priority
|
|
if (score > bestScore) {
|
|
bestScore = score;
|
|
bestIndex = index;
|
|
bestMatch = word;
|
|
}
|
|
}
|
|
});
|
|
|
|
if (bestIndex === -1) {
|
|
return this.escapeHtml(content.slice(0, maxLength)) + "...";
|
|
}
|
|
|
|
const start = Math.max(0, bestIndex - 50);
|
|
const end = Math.min(content.length, bestIndex + bestMatch.length + 50);
|
|
let preview = content.slice(start, end);
|
|
|
|
if (start > 0) preview = "..." + preview;
|
|
if (end < content.length) preview += "...";
|
|
|
|
// Escape HTML first, then highlight
|
|
preview = this.escapeHtml(preview);
|
|
preview = this.highlightTerms(preview, queryWords);
|
|
|
|
return preview;
|
|
}
|
|
|
|
// Escape HTML to prevent XSS
|
|
escapeHtml(text) {
|
|
const div = document.createElement('div');
|
|
div.textContent = text;
|
|
return div.innerHTML;
|
|
}
|
|
|
|
// Highlight search terms in text
|
|
highlightTerms(text, terms) {
|
|
let highlighted = text;
|
|
|
|
// Sort terms by length (longer first) to avoid overlapping highlights
|
|
const sortedTerms = [...terms].sort((a, b) => b.length - a.length);
|
|
|
|
sortedTerms.forEach(term => {
|
|
const regex = new RegExp(`(${this.escapeRegex(term)})`, 'gi');
|
|
highlighted = highlighted.replace(regex, '<mark>$1</mark>');
|
|
});
|
|
|
|
return highlighted;
|
|
}
|
|
|
|
// Web Worker search for large datasets
|
|
async searchWithWorker(query, limit) {
|
|
return new Promise((resolve, reject) => {
|
|
const messageId = `search_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
|
|
const timeout = setTimeout(() => {
|
|
cleanup();
|
|
reject(new Error('Web Worker search timeout'));
|
|
}, 5000); // 5 second timeout
|
|
|
|
const handleMessage = (e) => {
|
|
if (e.data.messageId !== messageId) return;
|
|
|
|
clearTimeout(timeout);
|
|
cleanup();
|
|
|
|
if (e.data.type === 'results') {
|
|
resolve(e.data.data);
|
|
} else if (e.data.type === 'error') {
|
|
reject(new Error(e.data.error || 'Unknown worker error'));
|
|
}
|
|
};
|
|
|
|
const handleError = (error) => {
|
|
clearTimeout(timeout);
|
|
cleanup();
|
|
reject(error);
|
|
};
|
|
|
|
const cleanup = () => {
|
|
searchWorker.removeEventListener('message', handleMessage);
|
|
searchWorker.removeEventListener('error', handleError);
|
|
};
|
|
|
|
searchWorker.addEventListener('message', handleMessage);
|
|
searchWorker.addEventListener('error', handleError);
|
|
|
|
searchWorker.postMessage({
|
|
messageId,
|
|
type: 'search',
|
|
data: { documents: this.documents, query, limit }
|
|
});
|
|
});
|
|
}
|
|
|
|
// Escape regex special characters
|
|
escapeRegex(string) {
|
|
return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
}
|
|
|
|
// Resolve path relative to current page location
|
|
resolvePath(path) {
|
|
// If path already starts with '/', it's absolute from domain root
|
|
if (path.startsWith('/')) {
|
|
return path;
|
|
}
|
|
|
|
// If path starts with '#', it's a fragment on current page
|
|
if (path.startsWith('#')) {
|
|
return path;
|
|
}
|
|
|
|
// Prepend root path for relative navigation
|
|
return this.rootPath + path;
|
|
}
|
|
|
|
// Optimized JSON parser for large files
|
|
async parseLargeJSON(response) {
|
|
const contentLength = response.headers.get('content-length');
|
|
|
|
// For small files, use regular JSON parsing
|
|
if (!contentLength || parseInt(contentLength) < 1024 * 1024) { // < 1MB
|
|
return await response.json();
|
|
}
|
|
|
|
// For large files, use streaming approach
|
|
console.log(`Large search file detected (${contentLength} bytes), using streaming parser`);
|
|
|
|
const reader = response.body.getReader();
|
|
const decoder = new TextDecoder("utf-8");
|
|
let buffer = "";
|
|
|
|
while (true) {
|
|
const { done, value } = await reader.read();
|
|
if (done) break;
|
|
|
|
buffer += decoder.decode(value, { stream: true });
|
|
|
|
// Process in chunks to avoid blocking main thread
|
|
if (buffer.length > 100 * 1024) { // 100KB chunks
|
|
await new Promise(resolve => setTimeout(resolve, 0));
|
|
}
|
|
}
|
|
|
|
return JSON.parse(buffer);
|
|
}
|
|
|
|
// Lazy loading for search results
|
|
async lazyLoadDocuments(docIds, limit = 10) {
|
|
if (!this.fullDocuments) {
|
|
// Store full documents separately for memory efficiency
|
|
this.fullDocuments = this.documents;
|
|
// Create lightweight index documents
|
|
this.documents = this.documents.map(doc => ({
|
|
id: doc.id,
|
|
title: doc.title,
|
|
path: doc.path
|
|
}));
|
|
}
|
|
|
|
return docIds.slice(0, limit).map(id => this.fullDocuments[id]);
|
|
}
|
|
|
|
// Fallback search method (simple string matching)
|
|
fallbackSearch(query, limit = 10) {
|
|
const lowerQuery = query.toLowerCase();
|
|
const results = this.documents
|
|
.map(doc => {
|
|
const titleMatch = doc.title.toLowerCase().indexOf(lowerQuery);
|
|
const contentMatch = doc.content.toLowerCase().indexOf(lowerQuery);
|
|
let score = 0;
|
|
|
|
if (titleMatch !== -1) {
|
|
score += 10;
|
|
if (doc.title.toLowerCase() === lowerQuery) {
|
|
score += 20;
|
|
}
|
|
}
|
|
if (contentMatch !== -1) {
|
|
score += 2;
|
|
}
|
|
|
|
return { doc, score, titleMatch, contentMatch };
|
|
})
|
|
.filter(item => item.score > 0)
|
|
.sort((a, b) => {
|
|
if (a.score !== b.score) return b.score - a.score;
|
|
if (a.titleMatch !== b.titleMatch) return a.titleMatch - b.titleMatch;
|
|
return a.contentMatch - b.contentMatch;
|
|
})
|
|
.slice(0, limit)
|
|
.map(item => ({ ...item.doc, score: item.score }));
|
|
|
|
return results;
|
|
}
|
|
}
|
|
|
|
// Web Worker for background search processing
|
|
// This is CLEARLY the best way to do it lmao.
|
|
// Create Web Worker if supported
|
|
let searchWorker = null;
|
|
if (typeof Worker !== 'undefined') {
|
|
try {
|
|
searchWorker = new Worker('/assets/search-worker.js');
|
|
console.log('Web Worker initialized for background search');
|
|
} catch (error) {
|
|
console.warn('Web Worker creation failed, using main thread:', error);
|
|
}
|
|
}
|
|
|
|
// Global search engine instance
|
|
window.searchNamespace.engine = new SearchEngine();
|
|
|
|
// Mobile search timeout for debouncing
|
|
let mobileSearchTimeout = null;
|
|
|
|
// Legacy search for backward compatibility
|
|
// This could be removed, but I'm emotionally attached to it
|
|
// and it could be used as a fallback.
|
|
function filterSearchResults(data, searchTerm, limit = 10) {
|
|
return data
|
|
.filter(
|
|
(doc) =>
|
|
doc.title.toLowerCase().includes(searchTerm) ||
|
|
doc.content.toLowerCase().includes(searchTerm),
|
|
)
|
|
.slice(0, limit);
|
|
}
|
|
|
|
document.addEventListener("DOMContentLoaded", function() {
|
|
// Initialize search engine immediately
|
|
window.searchNamespace.engine.loadData().then(() => {
|
|
console.log("Search data loaded successfully");
|
|
}).catch(error => {
|
|
console.error("Failed to initialize search:", error);
|
|
});
|
|
|
|
// Search page specific functionality
|
|
const searchPageInput = document.getElementById("search-page-input");
|
|
if (searchPageInput) {
|
|
// Set up event listener
|
|
searchPageInput.addEventListener("input", function() {
|
|
performSearch(this.value);
|
|
});
|
|
|
|
// Perform search if URL has query
|
|
const params = new URLSearchParams(window.location.search);
|
|
const query = params.get("q");
|
|
if (query) {
|
|
searchPageInput.value = query;
|
|
performSearch(query);
|
|
}
|
|
}
|
|
|
|
// Desktop Sidebar Toggle
|
|
const searchInput = document.getElementById("search-input");
|
|
if (searchInput) {
|
|
const searchResults = document.getElementById("search-results");
|
|
|
|
searchInput.addEventListener("input", async function() {
|
|
const searchTerm = this.value.trim();
|
|
|
|
if (searchTerm.length < 2) {
|
|
searchResults.innerHTML = "";
|
|
searchResults.style.display = "none";
|
|
return;
|
|
}
|
|
|
|
// Show loading state
|
|
searchResults.innerHTML = '<div class="search-result-item">Loading...</div>';
|
|
searchResults.style.display = "block";
|
|
|
|
try {
|
|
const results = await window.searchNamespace.engine.search(searchTerm, 8);
|
|
|
|
if (results.length > 0) {
|
|
searchResults.innerHTML = results
|
|
.map(
|
|
(doc) => {
|
|
const highlightedTitle = window.searchNamespace.engine.highlightTerms(
|
|
doc.title,
|
|
window.searchNamespace.engine.tokenize(searchTerm)
|
|
);
|
|
const resolvedPath = window.searchNamespace.engine.resolvePath(doc.path);
|
|
return `
|
|
<div class="search-result-item">
|
|
<a href="${resolvedPath}">${highlightedTitle}</a>
|
|
</div>
|
|
`;
|
|
},
|
|
)
|
|
.join("");
|
|
searchResults.style.display = "block";
|
|
} else {
|
|
searchResults.innerHTML =
|
|
'<div class="search-result-item">No results found</div>';
|
|
searchResults.style.display = "block";
|
|
}
|
|
} catch (error) {
|
|
console.error("Search error:", error);
|
|
searchResults.innerHTML =
|
|
'<div class="search-result-item">Search unavailable</div>';
|
|
searchResults.style.display = "block";
|
|
}
|
|
});
|
|
|
|
// Hide results when clicking outside
|
|
document.addEventListener("click", function(event) {
|
|
if (
|
|
!searchInput.contains(event.target) &&
|
|
!searchResults.contains(event.target)
|
|
) {
|
|
searchResults.style.display = "none";
|
|
}
|
|
});
|
|
|
|
// Focus search when pressing slash key
|
|
document.addEventListener("keydown", function(event) {
|
|
if (event.key === "/" && document.activeElement !== searchInput) {
|
|
event.preventDefault();
|
|
searchInput.focus();
|
|
}
|
|
});
|
|
}
|
|
|
|
// Mobile search functionality
|
|
// This detects mobile viewport and adds click behavior
|
|
function isMobile() {
|
|
return window.innerWidth <= 800;
|
|
}
|
|
|
|
if (searchInput) {
|
|
// Add mobile search behavior
|
|
searchInput.addEventListener("click", function(e) {
|
|
if (isMobile()) {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
openMobileSearch();
|
|
}
|
|
// On desktop, let the normal click behavior work (focus the input)
|
|
});
|
|
|
|
// Prevent typing on mobile (input should only open popup)
|
|
searchInput.addEventListener("keydown", function(e) {
|
|
if (isMobile()) {
|
|
e.preventDefault();
|
|
openMobileSearch();
|
|
}
|
|
});
|
|
}
|
|
|
|
// Mobile search popup functionality
|
|
let mobileSearchPopup = document.getElementById("mobile-search-popup");
|
|
let mobileSearchInput = document.getElementById("mobile-search-input");
|
|
let mobileSearchResults = document.getElementById("mobile-search-results");
|
|
const closeMobileSearchBtn = document.getElementById("close-mobile-search");
|
|
|
|
function openMobileSearch() {
|
|
if (mobileSearchPopup) {
|
|
mobileSearchPopup.classList.add("active");
|
|
// Focus the input after a small delay to ensure the popup is visible
|
|
setTimeout(() => {
|
|
if (mobileSearchInput) {
|
|
mobileSearchInput.focus();
|
|
}
|
|
}, 100);
|
|
}
|
|
}
|
|
|
|
function closeMobileSearch() {
|
|
if (mobileSearchPopup) {
|
|
mobileSearchPopup.classList.remove("active");
|
|
if (mobileSearchInput) {
|
|
mobileSearchInput.value = "";
|
|
}
|
|
if (mobileSearchResults) {
|
|
mobileSearchResults.innerHTML = "";
|
|
mobileSearchResults.style.display = "none";
|
|
}
|
|
}
|
|
}
|
|
|
|
if (closeMobileSearchBtn) {
|
|
closeMobileSearchBtn.addEventListener("click", closeMobileSearch);
|
|
}
|
|
|
|
// Close mobile search when clicking outside
|
|
document.addEventListener("click", function(event) {
|
|
if (
|
|
mobileSearchPopup &&
|
|
mobileSearchPopup.classList.contains("active") &&
|
|
!mobileSearchPopup.contains(event.target) &&
|
|
!searchInput.contains(event.target)
|
|
) {
|
|
closeMobileSearch();
|
|
}
|
|
});
|
|
|
|
// Close mobile search on escape key
|
|
document.addEventListener("keydown", function(event) {
|
|
if (
|
|
event.key === "Escape" &&
|
|
mobileSearchPopup &&
|
|
mobileSearchPopup.classList.contains("active")
|
|
) {
|
|
closeMobileSearch();
|
|
}
|
|
});
|
|
|
|
// Mobile search input
|
|
if (mobileSearchInput && mobileSearchResults) {
|
|
function handleMobileSearchInput() {
|
|
clearTimeout(mobileSearchTimeout);
|
|
const searchTerm = mobileSearchInput.value.trim();
|
|
if (searchTerm.length < 2) {
|
|
mobileSearchResults.innerHTML = "";
|
|
mobileSearchResults.style.display = "none";
|
|
return;
|
|
}
|
|
|
|
mobileSearchTimeout = setTimeout(async () => {
|
|
// Verify the input still matches before proceeding
|
|
if (mobileSearchInput.value.trim() !== searchTerm) return;
|
|
|
|
// Show loading state
|
|
mobileSearchResults.innerHTML = '<div class="search-result-item">Loading...</div>';
|
|
mobileSearchResults.style.display = "block";
|
|
|
|
try {
|
|
const results = await window.searchNamespace.engine.search(searchTerm, 8);
|
|
// Verify again after async operation
|
|
if (mobileSearchInput.value.trim() !== searchTerm) return;
|
|
|
|
if (results.length > 0) {
|
|
mobileSearchResults.innerHTML = results
|
|
.map(
|
|
(doc) => {
|
|
const highlightedTitle = window.searchNamespace.engine.highlightTerms(
|
|
doc.title,
|
|
window.searchNamespace.engine.tokenize(searchTerm)
|
|
);
|
|
const resolvedPath = window.searchNamespace.engine.resolvePath(doc.path);
|
|
return `
|
|
<div class="search-result-item">
|
|
<a href="${resolvedPath}">${highlightedTitle}</a>
|
|
</div>
|
|
`;
|
|
},
|
|
)
|
|
.join("");
|
|
mobileSearchResults.style.display = "block";
|
|
} else {
|
|
mobileSearchResults.innerHTML =
|
|
'<div class="search-result-item">No results found</div>';
|
|
mobileSearchResults.style.display = "block";
|
|
}
|
|
} catch (error) {
|
|
console.error("Mobile search error:", error);
|
|
// Verify once more
|
|
if (mobileSearchInput.value.trim() !== searchTerm) return;
|
|
mobileSearchResults.innerHTML =
|
|
'<div class="search-result-item">Search unavailable</div>';
|
|
mobileSearchResults.style.display = "block";
|
|
}
|
|
}, 300);
|
|
}
|
|
|
|
mobileSearchInput.addEventListener("input", handleMobileSearchInput);
|
|
}
|
|
|
|
// Handle window resize to update mobile behavior
|
|
window.addEventListener("resize", function() {
|
|
// Close mobile search if window is resized to desktop size
|
|
if (
|
|
!isMobile() &&
|
|
mobileSearchPopup &&
|
|
mobileSearchPopup.classList.contains("active")
|
|
) {
|
|
closeMobileSearch();
|
|
}
|
|
});
|
|
});
|
|
|
|
async function performSearch(query) {
|
|
query = query.trim();
|
|
const resultsContainer = document.getElementById("search-page-results");
|
|
|
|
if (query.length < 2) {
|
|
resultsContainer.innerHTML =
|
|
"<p>Please enter at least 2 characters to search</p>";
|
|
return;
|
|
}
|
|
|
|
// Show loading state
|
|
resultsContainer.innerHTML = "<p>Searching...</p>";
|
|
|
|
try {
|
|
const results = await window.searchNamespace.engine.search(query, 50);
|
|
|
|
// Display results
|
|
if (results.length > 0) {
|
|
let html = '<ul class="search-results-list">';
|
|
const queryTerms = window.searchNamespace.engine.tokenize(query);
|
|
|
|
for (const result of results) {
|
|
const highlightedTitle = window.searchNamespace.engine.highlightTerms(result.title, queryTerms);
|
|
const preview = window.searchNamespace.engine.generatePreview(result.content, query);
|
|
const resolvedPath = window.searchNamespace.engine.resolvePath(result.path);
|
|
html += `<li class="search-result-item">
|
|
<a href="${resolvedPath}">
|
|
<div class="search-result-title">${highlightedTitle}</div>
|
|
<div class="search-result-preview">${preview}</div>
|
|
</a>
|
|
</li>`;
|
|
}
|
|
html += "</ul>";
|
|
resultsContainer.innerHTML = html;
|
|
} else {
|
|
resultsContainer.innerHTML = "<p>No results found</p>";
|
|
}
|
|
|
|
// Update URL with query
|
|
const url = new URL(window.location.href);
|
|
url.searchParams.set("q", query);
|
|
window.history.replaceState({}, "", url.toString());
|
|
} catch (error) {
|
|
console.error("Search error:", error);
|
|
resultsContainer.innerHTML = "<p>Search temporarily unavailable</p>";
|
|
}
|
|
}
|