704 lines
23 KiB
JavaScript
704 lines
23 KiB
JavaScript
/**
|
|
* MITM Certificate Search Interface
|
|
* Provides search functionality for SSL MITM certificate data
|
|
*/
|
|
(function() {
|
|
'use strict';
|
|
|
|
// Theme toggle (shared with dashboard)
|
|
var themes = ['dark', 'muted-dark', 'light'];
|
|
function getTheme() {
|
|
if (document.documentElement.classList.contains('light')) return 'light';
|
|
if (document.documentElement.classList.contains('muted-dark')) return 'muted-dark';
|
|
return 'dark';
|
|
}
|
|
function setTheme(theme) {
|
|
document.documentElement.classList.remove('light', 'muted-dark');
|
|
if (theme === 'light') document.documentElement.classList.add('light');
|
|
else if (theme === 'muted-dark') document.documentElement.classList.add('muted-dark');
|
|
try { localStorage.setItem('ppf-theme', theme); } catch(e) {}
|
|
}
|
|
function initTheme() {
|
|
var saved = null;
|
|
try { saved = localStorage.getItem('ppf-theme'); } catch(e) {}
|
|
if (saved && themes.indexOf(saved) !== -1) {
|
|
setTheme(saved);
|
|
} else if (window.matchMedia && window.matchMedia('(prefers-color-scheme: light)').matches) {
|
|
setTheme('light');
|
|
}
|
|
var btn = document.getElementById('themeToggle');
|
|
if (btn) {
|
|
btn.addEventListener('click', function() {
|
|
var current = getTheme();
|
|
var idx = themes.indexOf(current);
|
|
var next = themes[(idx + 1) % themes.length];
|
|
setTheme(next);
|
|
});
|
|
}
|
|
}
|
|
document.addEventListener('DOMContentLoaded', initTheme);
|
|
|
|
// DOM elements
|
|
var searchInput = document.getElementById('searchInput');
|
|
var searchClear = document.getElementById('searchClear');
|
|
var suggestions = document.getElementById('suggestions');
|
|
var resultsContainer = document.getElementById('resultsContainer');
|
|
var resultsEmpty = document.getElementById('resultsEmpty');
|
|
var resultsLoading = document.getElementById('resultsLoading');
|
|
var resultsList = document.getElementById('resultsList');
|
|
var resultsContent = document.getElementById('resultsContent');
|
|
var resultsCount = document.getElementById('resultsCount');
|
|
var noDataState = document.getElementById('noDataState');
|
|
|
|
// Stats elements
|
|
var totalDetections = document.getElementById('totalDetections');
|
|
var uniqueCerts = document.getElementById('uniqueCerts');
|
|
var uniqueOrgs = document.getElementById('uniqueOrgs');
|
|
var uniqueProxies = document.getElementById('uniqueProxies');
|
|
|
|
// State
|
|
var searchTimeout = null;
|
|
var currentData = null;
|
|
var suggestionIndex = -1;
|
|
|
|
// Search field definitions for autocomplete
|
|
var searchFields = [
|
|
{ prefix: 'org:', desc: 'organization name', icon: '🏢' },
|
|
{ prefix: 'issuer:', desc: 'certificate issuer', icon: '📄' },
|
|
{ prefix: 'cn:', desc: 'common name', icon: '🔗' },
|
|
{ prefix: 'proxy:', desc: 'proxy IP address', icon: '🖥' },
|
|
{ prefix: 'fp:', desc: 'fingerprint', icon: '🔑' },
|
|
{ prefix: 'serial:', desc: 'serial number', icon: '🔢' },
|
|
{ prefix: 'expires:', desc: 'expiration year', icon: '📅' },
|
|
{ prefix: 'expired:', desc: 'yes/no', icon: '⚠' }
|
|
];
|
|
|
|
/**
|
|
* Escape HTML to prevent XSS
|
|
*/
|
|
function escapeHtml(str) {
|
|
if (str === null || str === undefined) return '';
|
|
var div = document.createElement('div');
|
|
div.textContent = String(str);
|
|
return div.innerHTML;
|
|
}
|
|
|
|
/**
|
|
* Sanitize search query - remove potentially dangerous characters
|
|
*/
|
|
function sanitizeQuery(query) {
|
|
if (!query) return '';
|
|
// Allow alphanumeric, spaces, common punctuation for searches
|
|
// Remove control characters and dangerous patterns
|
|
return String(query)
|
|
.replace(/[\x00-\x1f\x7f]/g, '') // Control characters
|
|
.replace(/<[^>]*>/g, '') // HTML tags
|
|
.trim()
|
|
.substring(0, 200); // Limit length
|
|
}
|
|
|
|
/**
|
|
* Format number with comma separators
|
|
*/
|
|
function formatNumber(n) {
|
|
if (n === null || n === undefined || n === '-') return '-';
|
|
return String(n).replace(/\B(?=(\d{3})+(?!\d))/g, ',');
|
|
}
|
|
|
|
/**
|
|
* Format timestamp to readable date
|
|
*/
|
|
function formatDate(ts) {
|
|
if (!ts) return '-';
|
|
// Handle ASN.1 time format (YYYYMMDDhhmmssZ)
|
|
if (typeof ts === 'string' && ts.length >= 14) {
|
|
var y = ts.substring(0, 4);
|
|
var m = ts.substring(4, 6);
|
|
var d = ts.substring(6, 8);
|
|
return y + '-' + m + '-' + d;
|
|
}
|
|
// Handle Unix timestamp
|
|
if (typeof ts === 'number') {
|
|
var date = new Date(ts * 1000);
|
|
return date.toISOString().split('T')[0];
|
|
}
|
|
return String(ts);
|
|
}
|
|
|
|
/**
|
|
* Check if certificate is expired
|
|
*/
|
|
function isExpired(notAfter) {
|
|
if (!notAfter || notAfter.length < 14) return false;
|
|
var expDate = new Date(
|
|
parseInt(notAfter.substring(0, 4)),
|
|
parseInt(notAfter.substring(4, 6)) - 1,
|
|
parseInt(notAfter.substring(6, 8))
|
|
);
|
|
return expDate < new Date();
|
|
}
|
|
|
|
/**
|
|
* Load initial stats
|
|
*/
|
|
function loadStats() {
|
|
fetch('/api/mitm')
|
|
.then(function(r) { return r.json(); })
|
|
.then(function(data) {
|
|
currentData = data;
|
|
|
|
// Update summary stats
|
|
totalDetections.textContent = formatNumber(data.total_detections || 0);
|
|
uniqueCerts.textContent = formatNumber(data.unique_certs || 0);
|
|
uniqueProxies.textContent = formatNumber(data.unique_proxies || 0);
|
|
|
|
// Count unique orgs from top_organizations
|
|
var orgCount = (data.top_organizations || []).length;
|
|
uniqueOrgs.textContent = formatNumber(orgCount);
|
|
|
|
// Show no data state if empty
|
|
if (!data.certificates || data.certificates.length === 0) {
|
|
document.getElementById('statsSummary').style.display = 'none';
|
|
document.querySelector('.search-box').style.display = 'none';
|
|
document.getElementById('helpBox').style.display = 'none';
|
|
resultsContainer.style.display = 'none';
|
|
noDataState.style.display = 'block';
|
|
}
|
|
})
|
|
.catch(function(err) {
|
|
console.error('Failed to load MITM stats:', err);
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Parse search query into structured filters
|
|
*/
|
|
function parseQuery(query) {
|
|
var filters = {
|
|
org: null,
|
|
issuer: null,
|
|
cn: null,
|
|
proxy: null,
|
|
fp: null,
|
|
serial: null,
|
|
expires: null,
|
|
expired: null,
|
|
text: []
|
|
};
|
|
|
|
if (!query) return filters;
|
|
|
|
// Match field:value patterns and quoted strings
|
|
var parts = query.match(/(\w+:"[^"]+"|"[^"]+"|[\w:.]+)/g) || [];
|
|
|
|
for (var i = 0; i < parts.length; i++) {
|
|
var part = parts[i];
|
|
var colonIdx = part.indexOf(':');
|
|
|
|
if (colonIdx > 0 && colonIdx < part.length - 1) {
|
|
var field = part.substring(0, colonIdx).toLowerCase();
|
|
var value = part.substring(colonIdx + 1).replace(/^"|"$/g, '');
|
|
|
|
if (filters.hasOwnProperty(field)) {
|
|
filters[field] = value;
|
|
} else {
|
|
filters.text.push(part);
|
|
}
|
|
} else {
|
|
// Plain text search
|
|
filters.text.push(part.replace(/^"|"$/g, ''));
|
|
}
|
|
}
|
|
|
|
return filters;
|
|
}
|
|
|
|
/**
|
|
* Check if certificate matches filters
|
|
*/
|
|
function matchesCert(cert, filters) {
|
|
// Organization filter
|
|
if (filters.org) {
|
|
var org = (cert.subject_o || '').toLowerCase();
|
|
if (org.indexOf(filters.org.toLowerCase()) === -1) return false;
|
|
}
|
|
|
|
// Issuer filter
|
|
if (filters.issuer) {
|
|
var issuer = (cert.issuer_cn || cert.issuer_o || '').toLowerCase();
|
|
if (issuer.indexOf(filters.issuer.toLowerCase()) === -1) return false;
|
|
}
|
|
|
|
// Common name filter
|
|
if (filters.cn) {
|
|
var cn = (cert.subject_cn || '').toLowerCase();
|
|
if (cn.indexOf(filters.cn.toLowerCase()) === -1) return false;
|
|
}
|
|
|
|
// Proxy filter
|
|
if (filters.proxy) {
|
|
var proxies = cert.proxies || [];
|
|
var found = false;
|
|
for (var i = 0; i < proxies.length; i++) {
|
|
if (proxies[i].indexOf(filters.proxy) !== -1) {
|
|
found = true;
|
|
break;
|
|
}
|
|
}
|
|
if (!found) return false;
|
|
}
|
|
|
|
// Fingerprint filter
|
|
if (filters.fp) {
|
|
var fp = (cert.fingerprint || cert.fingerprint_full || '').toLowerCase();
|
|
if (fp.indexOf(filters.fp.toLowerCase()) === -1) return false;
|
|
}
|
|
|
|
// Serial filter
|
|
if (filters.serial) {
|
|
var serial = (cert.serial || '').toLowerCase();
|
|
if (serial.indexOf(filters.serial.toLowerCase()) === -1) return false;
|
|
}
|
|
|
|
// Expiration year filter
|
|
if (filters.expires) {
|
|
var notAfter = cert.not_after || '';
|
|
if (notAfter.indexOf(filters.expires) === -1) return false;
|
|
}
|
|
|
|
// Expired filter
|
|
if (filters.expired) {
|
|
var wantExpired = filters.expired.toLowerCase() === 'yes';
|
|
var certExpired = isExpired(cert.not_after);
|
|
if (wantExpired !== certExpired) return false;
|
|
}
|
|
|
|
// Text search (searches all fields)
|
|
if (filters.text.length > 0) {
|
|
var searchable = [
|
|
cert.subject_cn || '',
|
|
cert.subject_o || '',
|
|
cert.issuer_cn || '',
|
|
cert.issuer_o || '',
|
|
cert.fingerprint || '',
|
|
cert.serial || ''
|
|
].join(' ').toLowerCase();
|
|
|
|
for (var j = 0; j < filters.text.length; j++) {
|
|
if (searchable.indexOf(filters.text[j].toLowerCase()) === -1) {
|
|
return false;
|
|
}
|
|
}
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
/**
|
|
* Perform search on current data
|
|
*/
|
|
function performSearch(query) {
|
|
query = sanitizeQuery(query);
|
|
|
|
if (!query) {
|
|
showEmpty();
|
|
return;
|
|
}
|
|
|
|
if (!currentData || !currentData.certificates) {
|
|
showEmpty();
|
|
return;
|
|
}
|
|
|
|
showLoading();
|
|
|
|
// Use setTimeout to not block UI
|
|
setTimeout(function() {
|
|
var filters = parseQuery(query);
|
|
var results = [];
|
|
|
|
var certs = currentData.certificates || [];
|
|
for (var i = 0; i < certs.length; i++) {
|
|
if (matchesCert(certs[i], filters)) {
|
|
results.push(certs[i]);
|
|
}
|
|
}
|
|
|
|
showResults(results, query);
|
|
}, 50);
|
|
}
|
|
|
|
/**
|
|
* Show empty state
|
|
*/
|
|
function showEmpty() {
|
|
resultsEmpty.style.display = 'block';
|
|
resultsLoading.style.display = 'none';
|
|
resultsList.style.display = 'none';
|
|
}
|
|
|
|
/**
|
|
* Show loading state
|
|
*/
|
|
function showLoading() {
|
|
resultsEmpty.style.display = 'none';
|
|
resultsLoading.style.display = 'block';
|
|
resultsList.style.display = 'none';
|
|
}
|
|
|
|
/**
|
|
* Render search results
|
|
*/
|
|
function showResults(results, query) {
|
|
resultsEmpty.style.display = 'none';
|
|
resultsLoading.style.display = 'none';
|
|
resultsList.style.display = 'block';
|
|
|
|
resultsCount.textContent = formatNumber(results.length);
|
|
|
|
if (results.length === 0) {
|
|
resultsContent.innerHTML =
|
|
'<div class="results-empty">' +
|
|
'<div class="results-empty-icon">🔍</div>' +
|
|
'<div class="results-empty-text">No certificates match your search</div>' +
|
|
'<div class="results-empty-hint">Try adjusting your filters or search terms</div>' +
|
|
'</div>';
|
|
return;
|
|
}
|
|
|
|
var html = '';
|
|
for (var i = 0; i < results.length; i++) {
|
|
html += renderCertCard(results[i]);
|
|
}
|
|
resultsContent.innerHTML = html;
|
|
}
|
|
|
|
/**
|
|
* Render a single certificate card
|
|
*/
|
|
function renderCertCard(cert) {
|
|
var expired = isExpired(cert.not_after);
|
|
var proxies = cert.proxies || [];
|
|
|
|
var html = '<div class="result-card">';
|
|
html += '<div class="result-header">';
|
|
html += '<div class="result-title">';
|
|
html += '<span class="result-badge cert">Certificate</span>';
|
|
html += escapeHtml(cert.subject_cn || cert.subject_o || 'Unknown');
|
|
html += '</div>';
|
|
html += '<div class="result-meta">';
|
|
if (expired) {
|
|
html += '<span class="red">Expired</span> · ';
|
|
}
|
|
html += 'Seen ' + escapeHtml(String(cert.count || 1)) + ' time(s)';
|
|
html += '</div>';
|
|
html += '</div>';
|
|
|
|
html += '<div class="result-body">';
|
|
|
|
// Subject
|
|
html += '<div class="result-field">';
|
|
html += '<div class="result-field-label">Subject (CN)</div>';
|
|
html += '<div class="result-field-value">' + escapeHtml(cert.subject_cn || '-') + '</div>';
|
|
html += '</div>';
|
|
|
|
// Organization
|
|
html += '<div class="result-field">';
|
|
html += '<div class="result-field-label">Organization</div>';
|
|
html += '<div class="result-field-value">' + escapeHtml(cert.subject_o || '-') + '</div>';
|
|
html += '</div>';
|
|
|
|
// Issuer
|
|
html += '<div class="result-field">';
|
|
html += '<div class="result-field-label">Issuer (CN)</div>';
|
|
html += '<div class="result-field-value">' + escapeHtml(cert.issuer_cn || '-') + '</div>';
|
|
html += '</div>';
|
|
|
|
// Issuer Org
|
|
html += '<div class="result-field">';
|
|
html += '<div class="result-field-label">Issuer Org</div>';
|
|
html += '<div class="result-field-value">' + escapeHtml(cert.issuer_o || '-') + '</div>';
|
|
html += '</div>';
|
|
|
|
// Fingerprint
|
|
html += '<div class="result-field">';
|
|
html += '<div class="result-field-label">Fingerprint</div>';
|
|
html += '<div class="result-field-value highlight">' + escapeHtml(cert.fingerprint || cert.fingerprint_full || '-') + '</div>';
|
|
html += '</div>';
|
|
|
|
// Serial
|
|
html += '<div class="result-field">';
|
|
html += '<div class="result-field-label">Serial</div>';
|
|
html += '<div class="result-field-value">' + escapeHtml(cert.serial || '-') + '</div>';
|
|
html += '</div>';
|
|
|
|
// Valid From
|
|
html += '<div class="result-field">';
|
|
html += '<div class="result-field-label">Valid From</div>';
|
|
html += '<div class="result-field-value">' + escapeHtml(formatDate(cert.not_before)) + '</div>';
|
|
html += '</div>';
|
|
|
|
// Valid Until
|
|
html += '<div class="result-field">';
|
|
html += '<div class="result-field-label">Valid Until</div>';
|
|
html += '<div class="result-field-value' + (expired ? ' red' : '') + '">' + escapeHtml(formatDate(cert.not_after)) + '</div>';
|
|
html += '</div>';
|
|
|
|
html += '</div>';
|
|
|
|
// Proxies list
|
|
if (proxies.length > 0) {
|
|
html += '<div class="result-proxies">';
|
|
html += '<div class="result-proxies-title">Proxies using this certificate (' + proxies.length + ')</div>';
|
|
html += '<div class="proxy-tags">';
|
|
for (var i = 0; i < Math.min(proxies.length, 10); i++) {
|
|
html += '<span class="proxy-tag">' + escapeHtml(proxies[i]) + '</span>';
|
|
}
|
|
if (proxies.length > 10) {
|
|
html += '<span class="proxy-tag">+' + (proxies.length - 10) + ' more</span>';
|
|
}
|
|
html += '</div>';
|
|
html += '</div>';
|
|
}
|
|
|
|
html += '</div>';
|
|
return html;
|
|
}
|
|
|
|
/**
|
|
* Show autocomplete suggestions
|
|
*/
|
|
function showSuggestions(query) {
|
|
query = sanitizeQuery(query);
|
|
|
|
if (!query) {
|
|
hideSuggestions();
|
|
return;
|
|
}
|
|
|
|
var html = '';
|
|
|
|
// Check if user is typing a field prefix
|
|
var lastWord = query.split(/\s+/).pop() || '';
|
|
var colonIdx = lastWord.indexOf(':');
|
|
|
|
if (colonIdx === -1) {
|
|
// Show matching field suggestions
|
|
var matchingFields = [];
|
|
for (var i = 0; i < searchFields.length; i++) {
|
|
if (searchFields[i].prefix.toLowerCase().indexOf(lastWord.toLowerCase()) === 0) {
|
|
matchingFields.push(searchFields[i]);
|
|
}
|
|
}
|
|
|
|
if (matchingFields.length > 0) {
|
|
html += '<div class="suggestion-group">';
|
|
html += '<div class="suggestion-header">Field Filters</div>';
|
|
for (var j = 0; j < matchingFields.length; j++) {
|
|
var field = matchingFields[j];
|
|
html += '<div class="suggestion-item" data-value="' + escapeHtml(field.prefix) + '">';
|
|
html += '<span class="suggestion-icon">' + field.icon + '</span>';
|
|
html += '<span class="suggestion-text"><code>' + escapeHtml(field.prefix) + '</code></span>';
|
|
html += '<span class="suggestion-desc">' + escapeHtml(field.desc) + '</span>';
|
|
html += '</div>';
|
|
}
|
|
html += '</div>';
|
|
}
|
|
}
|
|
|
|
// Add quick value suggestions from data
|
|
if (currentData && colonIdx > 0) {
|
|
var fieldName = lastWord.substring(0, colonIdx);
|
|
var valuePart = lastWord.substring(colonIdx + 1).toLowerCase();
|
|
|
|
var valueSuggestions = getValueSuggestions(fieldName, valuePart);
|
|
if (valueSuggestions.length > 0) {
|
|
html += '<div class="suggestion-group">';
|
|
html += '<div class="suggestion-header">Matching Values</div>';
|
|
for (var k = 0; k < Math.min(valueSuggestions.length, 5); k++) {
|
|
var val = valueSuggestions[k];
|
|
html += '<div class="suggestion-item" data-value="' + escapeHtml(fieldName + ':' + val) + '">';
|
|
html += '<span class="suggestion-icon">→</span>';
|
|
html += '<span class="suggestion-text">' + escapeHtml(val) + '</span>';
|
|
html += '</div>';
|
|
}
|
|
html += '</div>';
|
|
}
|
|
}
|
|
|
|
if (html) {
|
|
suggestions.innerHTML = html;
|
|
suggestions.classList.add('visible');
|
|
suggestionIndex = -1;
|
|
} else {
|
|
hideSuggestions();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get value suggestions for a field
|
|
*/
|
|
function getValueSuggestions(field, partial) {
|
|
if (!currentData) return [];
|
|
|
|
var values = [];
|
|
field = field.toLowerCase();
|
|
|
|
if (field === 'org') {
|
|
var orgs = currentData.top_organizations || [];
|
|
for (var i = 0; i < orgs.length; i++) {
|
|
var orgName = orgs[i].name || '';
|
|
if (partial === '' || orgName.toLowerCase().indexOf(partial) !== -1) {
|
|
values.push(orgName);
|
|
}
|
|
}
|
|
} else if (field === 'issuer') {
|
|
var issuers = currentData.top_issuers || [];
|
|
for (var j = 0; j < issuers.length; j++) {
|
|
var issuerName = issuers[j].name || '';
|
|
if (partial === '' || issuerName.toLowerCase().indexOf(partial) !== -1) {
|
|
values.push(issuerName);
|
|
}
|
|
}
|
|
} else if (field === 'expired') {
|
|
values = ['yes', 'no'];
|
|
}
|
|
|
|
return values;
|
|
}
|
|
|
|
/**
|
|
* Hide suggestions dropdown
|
|
*/
|
|
function hideSuggestions() {
|
|
suggestions.classList.remove('visible');
|
|
suggestionIndex = -1;
|
|
}
|
|
|
|
/**
|
|
* Apply suggestion to search input
|
|
*/
|
|
function applySuggestion(value) {
|
|
var query = searchInput.value;
|
|
var parts = query.split(/\s+/);
|
|
parts[parts.length - 1] = value;
|
|
searchInput.value = parts.join(' ');
|
|
searchInput.focus();
|
|
hideSuggestions();
|
|
|
|
// Trigger search if value is complete
|
|
if (value.indexOf(':') !== -1) {
|
|
handleSearch();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Handle search input
|
|
*/
|
|
function handleSearch() {
|
|
var query = searchInput.value;
|
|
|
|
// Show/hide clear button
|
|
if (query) {
|
|
searchClear.classList.add('visible');
|
|
} else {
|
|
searchClear.classList.remove('visible');
|
|
}
|
|
|
|
// Debounce search
|
|
if (searchTimeout) {
|
|
clearTimeout(searchTimeout);
|
|
}
|
|
|
|
searchTimeout = setTimeout(function() {
|
|
performSearch(query);
|
|
}, 200);
|
|
|
|
// Show suggestions immediately
|
|
showSuggestions(query);
|
|
}
|
|
|
|
/**
|
|
* Clear search
|
|
*/
|
|
function clearSearch() {
|
|
searchInput.value = '';
|
|
searchClear.classList.remove('visible');
|
|
hideSuggestions();
|
|
showEmpty();
|
|
searchInput.focus();
|
|
}
|
|
|
|
/**
|
|
* Toggle help box
|
|
*/
|
|
window.toggleHelp = function() {
|
|
var helpBox = document.getElementById('helpBox');
|
|
helpBox.classList.toggle('collapsed');
|
|
};
|
|
|
|
/**
|
|
* Keyboard navigation for suggestions
|
|
*/
|
|
function handleKeydown(e) {
|
|
var items = suggestions.querySelectorAll('.suggestion-item');
|
|
if (!items.length) return;
|
|
|
|
if (e.key === 'ArrowDown') {
|
|
e.preventDefault();
|
|
suggestionIndex = Math.min(suggestionIndex + 1, items.length - 1);
|
|
updateSuggestionHighlight(items);
|
|
} else if (e.key === 'ArrowUp') {
|
|
e.preventDefault();
|
|
suggestionIndex = Math.max(suggestionIndex - 1, 0);
|
|
updateSuggestionHighlight(items);
|
|
} else if (e.key === 'Enter' && suggestionIndex >= 0) {
|
|
e.preventDefault();
|
|
var value = items[suggestionIndex].getAttribute('data-value');
|
|
if (value) applySuggestion(value);
|
|
} else if (e.key === 'Escape') {
|
|
hideSuggestions();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Update suggestion highlight
|
|
*/
|
|
function updateSuggestionHighlight(items) {
|
|
for (var i = 0; i < items.length; i++) {
|
|
if (i === suggestionIndex) {
|
|
items[i].classList.add('active');
|
|
} else {
|
|
items[i].classList.remove('active');
|
|
}
|
|
}
|
|
}
|
|
|
|
// Event listeners
|
|
searchInput.addEventListener('input', handleSearch);
|
|
searchInput.addEventListener('keydown', handleKeydown);
|
|
searchInput.addEventListener('focus', function() {
|
|
if (searchInput.value) showSuggestions(searchInput.value);
|
|
});
|
|
searchClear.addEventListener('click', clearSearch);
|
|
|
|
// Click on suggestion
|
|
suggestions.addEventListener('click', function(e) {
|
|
var item = e.target.closest('.suggestion-item');
|
|
if (item) {
|
|
var value = item.getAttribute('data-value');
|
|
if (value) applySuggestion(value);
|
|
}
|
|
});
|
|
|
|
// Click outside to hide suggestions
|
|
document.addEventListener('click', function(e) {
|
|
if (!e.target.closest('.search-box')) {
|
|
hideSuggestions();
|
|
}
|
|
});
|
|
|
|
// Initialize
|
|
loadStats();
|
|
})();
|