themes/ehc/static/js/search.js
146 Zeilen · 4.6 KB · JavaScript
| 1 | (function () { |
| 2 | 'use strict'; |
| 3 | |
| 4 | let searchIndex = null; |
| 5 | let debounceTimer = null; |
| 6 | |
| 7 | const toggle = document.getElementById('search-toggle'); |
| 8 | const container = document.getElementById('search-container'); |
| 9 | const box = document.getElementById('search-box'); |
| 10 | const input = document.getElementById('search-input'); |
| 11 | const results = document.getElementById('search-results'); |
| 12 | |
| 13 | if (!toggle || !input || !results) return; |
| 14 | |
| 15 | // Toggle search box |
| 16 | toggle.addEventListener('click', function (e) { |
| 17 | e.stopPropagation(); |
| 18 | const isOpen = container.classList.toggle('active'); |
| 19 | if (isOpen) { |
| 20 | input.focus(); |
| 21 | loadIndex(); |
| 22 | } else { |
| 23 | closeSearch(); |
| 24 | } |
| 25 | }); |
| 26 | |
| 27 | // Load search index (lazy, only once) |
| 28 | function loadIndex() { |
| 29 | if (searchIndex !== null) return; |
| 30 | fetch('/search-index.json') |
| 31 | .then(function (res) { return res.json(); }) |
| 32 | .then(function (data) { searchIndex = data; }) |
| 33 | .catch(function (err) { |
| 34 | console.error('Search index load failed:', err); |
| 35 | }); |
| 36 | } |
| 37 | |
| 38 | // Search on input |
| 39 | input.addEventListener('input', function () { |
| 40 | clearTimeout(debounceTimer); |
| 41 | debounceTimer = setTimeout(doSearch, 200); |
| 42 | }); |
| 43 | |
| 44 | function doSearch() { |
| 45 | var query = input.value.trim().toLowerCase(); |
| 46 | if (!query || !searchIndex) { |
| 47 | results.innerHTML = ''; |
| 48 | results.classList.remove('visible'); |
| 49 | return; |
| 50 | } |
| 51 | |
| 52 | var terms = query.split(/\s+/); |
| 53 | var matches = searchIndex.filter(function (entry) { |
| 54 | var haystack = [ |
| 55 | entry.title, |
| 56 | entry.preview, |
| 57 | (entry.tags || []).join(' '), |
| 58 | (entry.categories || []).join(' ') |
| 59 | ].join(' ').toLowerCase(); |
| 60 | |
| 61 | return terms.every(function (term) { |
| 62 | return haystack.indexOf(term) !== -1; |
| 63 | }); |
| 64 | }); |
| 65 | |
| 66 | renderResults(matches.slice(0, 8)); |
| 67 | } |
| 68 | |
| 69 | function renderResults(items) { |
| 70 | if (items.length === 0) { |
| 71 | results.innerHTML = '<div class="search-no-results">Keine Ergebnisse gefunden</div>'; |
| 72 | results.classList.add('visible'); |
| 73 | return; |
| 74 | } |
| 75 | |
| 76 | var html = ''; |
| 77 | items.forEach(function (item) { |
| 78 | var tags = (item.tags || []).slice(0, 3).map(function (t) { |
| 79 | return '<span class="search-result-tag">' + escapeHtml(t) + '</span>'; |
| 80 | }).join(''); |
| 81 | |
| 82 | html += '<a href="' + escapeHtml(item.url) + '" class="search-result-item">' + |
| 83 | '<div class="search-result-title">' + highlightMatch(escapeHtml(item.title), input.value.trim()) + '</div>' + |
| 84 | '<div class="search-result-meta">' + |
| 85 | '<span class="search-result-date">' + escapeHtml(item.date) + '</span>' + |
| 86 | (tags ? '<span class="search-result-tags">' + tags + '</span>' : '') + |
| 87 | '</div>' + |
| 88 | '</a>'; |
| 89 | }); |
| 90 | |
| 91 | results.innerHTML = html; |
| 92 | results.classList.add('visible'); |
| 93 | } |
| 94 | |
| 95 | function highlightMatch(text, query) { |
| 96 | if (!query) return text; |
| 97 | var terms = query.toLowerCase().split(/\s+/); |
| 98 | terms.forEach(function (term) { |
| 99 | if (!term) return; |
| 100 | var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + ')', 'gi'); |
| 101 | text = text.replace(regex, '<mark>$1</mark>'); |
| 102 | }); |
| 103 | return text; |
| 104 | } |
| 105 | |
| 106 | function escapeHtml(str) { |
| 107 | if (!str) return ''; |
| 108 | return str.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"'); |
| 109 | } |
| 110 | |
| 111 | function closeSearch() { |
| 112 | container.classList.remove('active'); |
| 113 | input.value = ''; |
| 114 | results.innerHTML = ''; |
| 115 | results.classList.remove('visible'); |
| 116 | } |
| 117 | |
| 118 | // Keyboard shortcuts |
| 119 | input.addEventListener('keydown', function (e) { |
| 120 | if (e.key === 'Escape') { |
| 121 | closeSearch(); |
| 122 | } |
| 123 | }); |
| 124 | |
| 125 | // Close on click outside |
| 126 | document.addEventListener('click', function (e) { |
| 127 | if (!container.contains(e.target)) { |
| 128 | closeSearch(); |
| 129 | } |
| 130 | }); |
| 131 | |
| 132 | // Prevent search box clicks from closing |
| 133 | box.addEventListener('click', function (e) { |
| 134 | e.stopPropagation(); |
| 135 | }); |
| 136 | |
| 137 | // Global shortcut: Ctrl+K or Cmd+K to open search |
| 138 | document.addEventListener('keydown', function (e) { |
| 139 | if ((e.ctrlKey || e.metaKey) && e.key === 'k') { |
| 140 | e.preventDefault(); |
| 141 | container.classList.add('active'); |
| 142 | input.focus(); |
| 143 | loadIndex(); |
| 144 | } |
| 145 | }); |
| 146 | })(); |