Skip to content

Commit 2d786de

Browse files
committed
feat: Implement search functionality
Adds a search modal and Fuse.js integration for site search. Includes search index generation, UI elements, and JS logic.
1 parent 544c48b commit 2d786de

File tree

11 files changed

+448
-91
lines changed

11 files changed

+448
-91
lines changed

.github/workflows/main.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ name: Deploy Hugo site to Pages
22

33
on:
44
push:
5-
branches: ["main"]
5+
branches: ["main", "search"]
66

77
permissions:
88
contents: read

assets/js/search.js

Lines changed: 170 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,170 @@
1+
(function () {
2+
'use strict';
3+
4+
// --- Search Modal ---
5+
const searchModal = document.querySelector('[data-search-modal]');
6+
const searchTriggers = document.querySelectorAll('[data-target="search-modal"]');
7+
const searchClose = document.querySelector('[data-search-close]');
8+
const searchInput = document.getElementById('search-input');
9+
const searchResults = document.getElementById('search-results');
10+
const searchPlaceholder = document.getElementById('search-placeholder');
11+
12+
if (!searchModal) return;
13+
14+
let fuse;
15+
let searchData;
16+
let isFuseInitialized = false;
17+
18+
async function initFuse() {
19+
if (isFuseInitialized) return;
20+
try {
21+
const response = await fetch('/searchindex.json');
22+
if (!response.ok) {
23+
throw new Error('Search index not found');
24+
}
25+
searchData = await response.json();
26+
const options = {
27+
keys: [
28+
{ name: 'title', weight: 0.8 },
29+
{ name: 'content', weight: 0.5 },
30+
{ name: 'description', weight: 0.6 },
31+
{ name: 'tags', weight: 0.4 },
32+
{ name: 'categories', weight: 0.4 }
33+
],
34+
includeMatches: true,
35+
minMatchCharLength: 2,
36+
threshold: 0.3, // Lowered from 0.4 to make search stricter
37+
};
38+
fuse = new Fuse(searchData, options);
39+
isFuseInitialized = true;
40+
console.log('Fuse.js initialized.');
41+
} catch (e) {
42+
console.error('Failed to initialize Fuse.js:', e);
43+
}
44+
}
45+
46+
const showModal = () => {
47+
searchModal.classList.remove('hidden');
48+
searchModal.classList.add('flex');
49+
searchInput.focus();
50+
document.body.style.overflow = 'hidden';
51+
initFuse(); // Initialize on first open
52+
};
53+
54+
const hideModal = () => {
55+
searchModal.classList.add('hidden');
56+
searchModal.classList.remove('flex');
57+
searchInput.value = '';
58+
searchResults.innerHTML = '';
59+
if (searchPlaceholder) {
60+
searchResults.appendChild(searchPlaceholder);
61+
}
62+
document.body.style.overflow = '';
63+
};
64+
65+
searchTriggers.forEach(trigger => {
66+
trigger.addEventListener('click', (e) => {
67+
e.preventDefault();
68+
showModal();
69+
});
70+
});
71+
72+
if (searchClose) searchClose.addEventListener('click', hideModal);
73+
74+
searchModal.addEventListener('click', (e) => {
75+
if (e.target === searchModal) {
76+
hideModal();
77+
}
78+
});
79+
80+
document.addEventListener('keydown', (e) => {
81+
if (e.key === 'Escape' && !searchModal.classList.contains('hidden')) {
82+
hideModal();
83+
}
84+
// Hotkey: Ctrl+K or Cmd+K
85+
if ((e.ctrlKey || e.metaKey) && e.key === 'k') {
86+
e.preventDefault();
87+
showModal();
88+
}
89+
});
90+
91+
if(searchInput) {
92+
searchInput.addEventListener('input', () => {
93+
if (!isFuseInitialized) {
94+
searchResults.innerHTML = '<div class="text-center text-gray-500 py-4">Initializing search...</div>';
95+
return;
96+
}
97+
const query = searchInput.value.trim();
98+
if (query.length < 2) {
99+
searchResults.innerHTML = '';
100+
if (searchPlaceholder) searchResults.appendChild(searchPlaceholder);
101+
return;
102+
}
103+
104+
const results = fuse.search(query, { limit: 20 });
105+
renderResults(results, query);
106+
});
107+
}
108+
109+
function renderResults(results, query) {
110+
searchResults.innerHTML = '';
111+
if (results.length === 0) {
112+
searchResults.innerHTML = `<div class="text-center text-gray-500 py-4">No results found for "${query}"</div>`;
113+
return;
114+
}
115+
116+
const resultList = document.createElement('ul');
117+
resultList.className = 'divide-y divide-border dark:divide-darkmode-border';
118+
119+
results.forEach(({ item, matches }) => {
120+
const li = document.createElement('li');
121+
li.className = 'p-4 hover:bg-theme-light dark:hover:bg-darkmode-theme-dark/50 rounded-md';
122+
123+
let title = item.title;
124+
let description = item.description || (item.content ? item.content.substring(0, 150) + '...' : '');
125+
126+
const titleMatch = matches.find(m => m.key === 'title');
127+
if (titleMatch) {
128+
title = highlight(title, titleMatch.indices);
129+
}
130+
131+
const descriptionMatch = matches.find(m => m.key === 'description');
132+
if (descriptionMatch) {
133+
description = highlight(description, descriptionMatch.indices);
134+
} else {
135+
const contentMatch = matches.find(m => m.key === 'content');
136+
if (contentMatch && item.content) {
137+
const start = Math.max(0, contentMatch.indices[0][0] - 30);
138+
const end = Math.min(item.content.length, contentMatch.indices[0][1] + 30);
139+
let snippet = item.content.substring(start, end);
140+
// Adjust indices for snippet
141+
const adjustedIndices = contentMatch.indices.map(([i, j]) => [i - start, j - start]);
142+
description = (start > 0 ? '...' : '') + highlight(snippet, adjustedIndices) + (end < item.content.length ? '...' : '');
143+
}
144+
}
145+
146+
li.innerHTML = `
147+
<a href="${item.permalink}" class="block">
148+
<h4 class="text-lg font-semibold text-primary dark:text-darkmode-primary">${title}</h4>
149+
<p class="text-sm text-text dark:text-darkmode-text mt-1">${description}</p>
150+
</a>
151+
`;
152+
resultList.appendChild(li);
153+
});
154+
searchResults.appendChild(resultList);
155+
}
156+
157+
function highlight(text, indices) {
158+
if (!text) return '';
159+
let result = '';
160+
let lastIndex = 0;
161+
indices.forEach(([start, end]) => {
162+
result += text.substring(lastIndex, start);
163+
result += `<mark class="bg-fuchsia-200/75 dark:bg-fuchsia-500/50 rounded px-1 py-0.5">${text.substring(start, end + 1)}</mark>`;
164+
lastIndex = end + 1;
165+
});
166+
result += text.substring(lastIndex);
167+
return result;
168+
}
169+
170+
})();

assets/scss/navigation.scss

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -55,16 +55,19 @@ background-color: #fff !important;
5555
@apply text-dark hover:text-primary dark:text-darkmode-text dark:hover:text-darkmode-primary block py-1 font-semibold transition;
5656
}
5757

58-
// Custom styles for theme-switcher to ensure correct color override
59-
.navbar .theme-switcher {
58+
// Custom styles for theme-switcher and search-btn to ensure correct color override
59+
.navbar .theme-switcher,
60+
.navbar .search-btn {
61+
@apply flex items-center justify-center h-10 w-10 rounded-full transition-colors duration-300;
6062
@apply bg-gray-100 text-gray-500;
6163

6264
&:hover {
6365
@apply bg-gray-100 text-gray-700;
6466
}
6567
}
6668

67-
.dark .navbar .theme-switcher {
69+
.dark .navbar .theme-switcher,
70+
.dark .navbar .search-btn {
6871
@apply bg-darkmode-theme-dark text-darkmode-light;
6972

7073
&:hover {

config/_default/params.toml

Lines changed: 6 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -45,15 +45,14 @@ label_sub = "(2100+ joined)"
4545
link = "https://discord.gg/hUygPUdD8E"
4646

4747
# search
48-
# search module: https://github.com/gethugothemes/hugo-modules/tree/master/search
4948
[search]
50-
enable = false
51-
primary_color = "#121212"
52-
include_sections = ["blog"]
53-
show_image = true
49+
enable = true
50+
primary_color = "#667eea"
51+
include_sections = ["blog", "workshops", "neuromorphic-computing/hardware", "neuromorphic-computing/software", "contributors", "getting-involved", "neuromorphic-computing/initiatives", "volunteer-opportunities"]
52+
show_image = false
5453
show_description = true
55-
show_tags = false
56-
show_categories = false
54+
show_tags = true
55+
show_categories = true
5756

5857

5958
# seo meta data for OpenGraph / Twitter Card

hugo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,7 @@ target = '$1'
5656

5757
############################# Outputs ############################
5858
[outputs]
59-
home = ["HTML", "RSS", "videoSitemap"]
59+
home = ["HTML", "RSS", "videoSitemap", "SearchIndex"]
6060
section = ["HTML", "RSS"] # Add this line
6161
############################# Imaging ############################
6262
[imaging]

layouts/_default/baseof.html

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,7 @@
4444
{{ partial "essentials/footer.html" . }}
4545
{{ partial "essentials/script.html" . }}
4646
{{ end }}
47-
47+
4848
<!-- OG Image Modal -->
4949
<div id="og-image-modal" class="fixed inset-0 bg-black bg-opacity-70 z-[100] hidden items-center justify-center p-4 backdrop-blur-sm">
5050
<div class="bg-white dark:bg-darkmode-theme-dark rounded-lg shadow-xl w-full max-w-4xl max-h-[90vh] flex flex-col">
@@ -60,5 +60,28 @@ <h3 class="text-xl font-bold">Social Media Assets</h3>
6060
</div>
6161
</div>
6262
</div>
63+
64+
{{ if site.Params.search.enable }}
65+
<!-- Search Modal -->
66+
<div id="search-modal" class="fixed inset-0 bg-black bg-opacity-50 z-[100] hidden items-start justify-center pt-16 md:pt-24 backdrop-blur-sm" data-search-modal>
67+
<div class="bg-white dark:bg-darkmode-theme-dark rounded-lg shadow-xl w-full max-w-3xl max-h-[80vh] flex flex-col" role="dialog" aria-modal="true" aria-labelledby="search-modal-title">
68+
<div class="flex justify-between items-center p-4 border-b border-border dark:border-darkmode-border flex-shrink-0">
69+
<h3 id="search-modal-title" class="text-xl font-bold">Search</h3>
70+
<button class="p-2 -mr-2 text-2xl hover:text-primary dark:hover:text-darkmode-primary" aria-label="Close search" data-search-close>×</button>
71+
</div>
72+
<div class="p-4 flex-shrink-0">
73+
<input type="text" id="search-input" class="form-input w-full" placeholder="Type to search..." autocomplete="off">
74+
</div>
75+
<div id="search-results" class="overflow-y-auto p-4 space-y-2">
76+
<div class="text-center text-gray-500 py-4" id="search-placeholder">Start typing to see results.</div>
77+
</div>
78+
</div>
79+
</div>
80+
81+
<!-- Search Scripts -->
82+
<script src="https://cdn.jsdelivr.net/npm/fuse.js@7.1.0/dist/fuse.min.js"></script>
83+
{{ $searchJS := resources.Get "js/search.js" | minify | fingerprint }}
84+
<script src="{{ $searchJS.RelPermalink }}" integrity="{{ $searchJS.Data.Integrity }}"></script>
85+
{{ end }}
6386
</body>
6487
</html>

0 commit comments

Comments
 (0)