httpd: extract static files to separate directory
This commit is contained in:
703
static/mitm.js
Normal file
703
static/mitm.js
Normal file
@@ -0,0 +1,703 @@
|
||||
/**
|
||||
* 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();
|
||||
})();
|
||||
Reference in New Issue
Block a user