Skip to content

Commit b5b931f

Browse files
Slashgearclaude
andcommitted
feat: add client-side event search with Fuse.js
Add a search feature that lets users search across all past and upcoming events by title, talk name, speaker name, or sponsor. Uses a static JSON index generated at build time and Fuse.js for fuzzy matching on the client. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 480a682 commit b5b931f

10 files changed

Lines changed: 594 additions & 4 deletions

File tree

.gitignore

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,4 +39,7 @@ yarn-error.log*
3939
/playwright/.cache/
4040

4141
# IDE
42-
.idea
42+
.idea
43+
44+
# Generated search index
45+
/public/search-index.json

modules/header/Header.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { LogoWithText } from '../icons/LogoWithText';
44
import { SocialLinks } from './SocialLinks';
55
import { Navbar } from '../navigation/Navbar';
66
import { MobileNavigation } from '../navigation/mobile/MobileNavigation';
7+
import { SearchDialog } from '../search/SearchDialog';
78

89
export const Header = () => (
910
<header className={styles.header}>
@@ -14,6 +15,7 @@ export const Header = () => (
1415
<MobileNavigation />
1516

1617
<Navbar className={styles.navbar} />
18+
<SearchDialog />
1719
<SocialLinks className={styles.socialLinks} />
1820
</header>
1921
);

modules/icons/Search.tsx

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
import React, { FC } from 'react';
2+
import { IconProps } from './types';
3+
4+
export const Search: FC<IconProps> = ({ color = 'currentColor', size = 20 }) => (
5+
<svg aria-hidden="true" width={size} height={size} viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
6+
<path
7+
d="M15.5 14h-.79l-.28-.27A6.471 6.471 0 0 0 16 9.5 6.5 6.5 0 1 0 9.5 16c1.61 0 3.09-.59 4.23-1.57l.27.28v.79l5 4.99L20.49 19l-4.99-5zm-6 0C7.01 14 5 11.99 5 9.5S7.01 5 9.5 5 14 7.01 14 9.5 11.99 14 9.5 14z"
8+
fill={color}
9+
/>
10+
</svg>
11+
);
Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,133 @@
1+
.searchButton {
2+
display: flex;
3+
align-items: center;
4+
justify-content: center;
5+
gap: 6px;
6+
background: none;
7+
border: none;
8+
cursor: pointer;
9+
color: var(--font-color-default);
10+
padding: 8px;
11+
border-radius: 8px;
12+
transition: color 200ms;
13+
}
14+
15+
.searchButton:hover {
16+
color: var(--font-color-strong);
17+
}
18+
19+
@media (max-width: 900px) {
20+
.searchButton {
21+
position: absolute;
22+
right: 55px;
23+
top: 14px;
24+
z-index: 999;
25+
}
26+
}
27+
28+
.overlay {
29+
position: fixed;
30+
inset: 0;
31+
z-index: 998;
32+
background: rgb(0 0 0 / 0.6);
33+
display: flex;
34+
justify-content: center;
35+
padding-top: min(15vh, 120px);
36+
}
37+
38+
.container {
39+
width: 100%;
40+
max-width: 600px;
41+
max-height: 70vh;
42+
display: flex;
43+
flex-direction: column;
44+
background: var(--background-page);
45+
border: 1px solid var(--border-light);
46+
border-radius: 12px;
47+
overflow: hidden;
48+
margin: 0 16px;
49+
align-self: flex-start;
50+
}
51+
52+
.inputWrapper {
53+
display: flex;
54+
align-items: center;
55+
gap: 8px;
56+
padding: 12px 16px;
57+
border-bottom: 1px solid var(--border-light);
58+
}
59+
60+
.inputIcon {
61+
flex-shrink: 0;
62+
color: var(--font-color-default);
63+
}
64+
65+
.input {
66+
flex: 1;
67+
background: none;
68+
border: none;
69+
outline: none;
70+
font-size: 16px;
71+
color: var(--font-color-strong);
72+
font-family: inherit;
73+
}
74+
75+
.input::placeholder {
76+
color: var(--font-color-default);
77+
}
78+
79+
.results {
80+
overflow-y: auto;
81+
padding: 8px 0;
82+
}
83+
84+
.resultItem {
85+
display: block;
86+
padding: 12px 16px;
87+
transition: background-color 150ms;
88+
color: inherit;
89+
}
90+
91+
.resultItem:hover,
92+
.resultItem:focus-visible {
93+
background-color: var(--background-card-hover);
94+
outline: none;
95+
}
96+
97+
.resultTitle {
98+
font-size: 15px;
99+
font-weight: 500;
100+
color: var(--font-color-strong);
101+
}
102+
103+
.resultMeta {
104+
font-size: 13px;
105+
color: var(--font-color-default);
106+
margin-top: 4px;
107+
display: flex;
108+
flex-wrap: wrap;
109+
gap: 4px 12px;
110+
}
111+
112+
.emptyState {
113+
padding: 32px 16px;
114+
text-align: center;
115+
color: var(--font-color-default);
116+
font-size: 14px;
117+
}
118+
119+
.kbd {
120+
display: none;
121+
font-size: 11px;
122+
padding: 2px 6px;
123+
border: 1px solid var(--border-light);
124+
border-radius: 4px;
125+
color: var(--font-color-default);
126+
font-family: inherit;
127+
}
128+
129+
@media (min-width: 900px) {
130+
.kbd {
131+
display: inline;
132+
}
133+
}

modules/search/SearchDialog.tsx

Lines changed: 211 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,211 @@
1+
'use client';
2+
3+
import { useCallback, useEffect, useRef, useState } from 'react';
4+
import type Fuse from 'fuse.js';
5+
import Link from 'next/link';
6+
import { usePathname } from 'next/navigation';
7+
import { Search } from '../icons/Search';
8+
import styles from './SearchDialog.module.css';
9+
10+
type SearchIndexEntry = {
11+
id: string;
12+
title: string;
13+
dateTime: string;
14+
slug: string;
15+
description?: string;
16+
talks?: Array<{ title: string; speakers?: Array<{ name: string }> }>;
17+
sponsor?: string;
18+
};
19+
20+
const dateFormatter = new Intl.DateTimeFormat('fr-FR', {
21+
day: 'numeric',
22+
month: 'long',
23+
year: 'numeric',
24+
});
25+
26+
function getSpeakers(entry: SearchIndexEntry) {
27+
if (!entry.talks) return '';
28+
const speakers = entry.talks.flatMap((t) => t.speakers?.map((s) => s.name) || []);
29+
return speakers.join(', ');
30+
}
31+
32+
function formatDate(dateTime: string) {
33+
try {
34+
return dateFormatter.format(new Date(dateTime));
35+
} catch {
36+
return '';
37+
}
38+
}
39+
40+
export const SearchDialog = () => {
41+
const [open, setOpen] = useState(false);
42+
const [query, setQuery] = useState('');
43+
const [results, setResults] = useState<SearchIndexEntry[]>([]);
44+
const [index, setIndex] = useState<SearchIndexEntry[] | null>(null);
45+
const [loading, setLoading] = useState(false);
46+
47+
const fuseRef = useRef<Fuse<SearchIndexEntry> | null>(null);
48+
const inputRef = useRef<HTMLInputElement>(null);
49+
const overlayRef = useRef<HTMLDivElement>(null);
50+
const debounceRef = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
51+
const pathname = usePathname();
52+
53+
// Close on route change
54+
useEffect(() => {
55+
setOpen(false);
56+
}, [pathname]);
57+
58+
// Scroll lock
59+
useEffect(() => {
60+
document.body.toggleAttribute('data-lock-scroll', open);
61+
return () => {
62+
document.body.removeAttribute('data-lock-scroll');
63+
};
64+
}, [open]);
65+
66+
// Keyboard shortcut: Ctrl/Cmd+K to open
67+
useEffect(() => {
68+
const handleKeyDown = (e: KeyboardEvent) => {
69+
if ((e.metaKey || e.ctrlKey) && e.key === 'k') {
70+
e.preventDefault();
71+
setOpen((prev) => !prev);
72+
}
73+
};
74+
document.addEventListener('keydown', handleKeyDown);
75+
return () => document.removeEventListener('keydown', handleKeyDown);
76+
}, []);
77+
78+
// Load index + Fuse.js on first open
79+
useEffect(() => {
80+
if (!open || index) return;
81+
82+
setLoading(true);
83+
Promise.all([
84+
fetch('/search-index.json').then((r) => r.json() as Promise<SearchIndexEntry[]>),
85+
import('fuse.js'),
86+
]).then(([data, FuseModule]) => {
87+
const Fuse = FuseModule.default;
88+
setIndex(data);
89+
fuseRef.current = new Fuse(data, {
90+
keys: [
91+
{ name: 'title', weight: 2 },
92+
{ name: 'talks.title', weight: 1.5 },
93+
{ name: 'talks.speakers.name', weight: 1.5 },
94+
{ name: 'description', weight: 0.5 },
95+
{ name: 'sponsor', weight: 0.8 },
96+
],
97+
threshold: 0.3,
98+
includeScore: true,
99+
});
100+
setLoading(false);
101+
});
102+
}, [open, index]);
103+
104+
// Focus input when dialog opens
105+
useEffect(() => {
106+
if (open) {
107+
requestAnimationFrame(() => inputRef.current?.focus());
108+
} else {
109+
setQuery('');
110+
setResults([]);
111+
}
112+
}, [open]);
113+
114+
// Debounced search
115+
const search = useCallback((value: string) => {
116+
if (debounceRef.current) clearTimeout(debounceRef.current);
117+
debounceRef.current = setTimeout(() => {
118+
if (!fuseRef.current || !value.trim()) {
119+
setResults([]);
120+
return;
121+
}
122+
const fuseResults = fuseRef.current.search(value, { limit: 20 });
123+
setResults(fuseResults.map((r) => r.item));
124+
}, 200);
125+
}, []);
126+
127+
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
128+
const value = e.target.value;
129+
setQuery(value);
130+
search(value);
131+
};
132+
133+
const handleOverlayClick = (e: React.MouseEvent) => {
134+
if (e.target === overlayRef.current) {
135+
setOpen(false);
136+
}
137+
};
138+
139+
const handleKeyDown = (e: React.KeyboardEvent) => {
140+
if (e.key === 'Escape') {
141+
setOpen(false);
142+
}
143+
};
144+
145+
return (
146+
<>
147+
<button className={styles.searchButton} onClick={() => setOpen(true)} aria-label="Rechercher" type="button">
148+
<Search size={20} />
149+
<kbd className={styles.kbd}>⌘K</kbd>
150+
</button>
151+
152+
{open && (
153+
<div
154+
className={styles.overlay}
155+
ref={overlayRef}
156+
onClick={handleOverlayClick}
157+
onKeyDown={handleKeyDown}
158+
role="dialog"
159+
aria-label="Rechercher un événement"
160+
aria-modal="true"
161+
>
162+
<div className={styles.container}>
163+
<div className={styles.inputWrapper}>
164+
<Search size={18} className={styles.inputIcon} />
165+
<input
166+
ref={inputRef}
167+
className={styles.input}
168+
type="search"
169+
placeholder="Rechercher un événement, un talk, un speaker..."
170+
value={query}
171+
onChange={handleInputChange}
172+
aria-label="Rechercher"
173+
/>
174+
</div>
175+
176+
<div className={styles.results}>
177+
{loading && <div className={styles.emptyState}>Chargement...</div>}
178+
179+
{!loading && query && results.length === 0 && (
180+
<div className={styles.emptyState}>Aucun résultat pour &laquo; {query} &raquo;</div>
181+
)}
182+
183+
{results.map((entry) => {
184+
const speakers = getSpeakers(entry);
185+
return (
186+
<Link
187+
key={entry.id}
188+
href={`/evenement/${entry.slug}`}
189+
className={styles.resultItem}
190+
onClick={() => setOpen(false)}
191+
>
192+
<div className={styles.resultTitle}>{entry.title}</div>
193+
<div className={styles.resultMeta}>
194+
<span>{formatDate(entry.dateTime)}</span>
195+
{speakers && <span>{speakers}</span>}
196+
{entry.sponsor && <span>{entry.sponsor}</span>}
197+
</div>
198+
</Link>
199+
);
200+
})}
201+
202+
{!loading && !query && index && (
203+
<div className={styles.emptyState}>Tapez pour rechercher parmi {index.length} événements</div>
204+
)}
205+
</div>
206+
</div>
207+
</div>
208+
)}
209+
</>
210+
);
211+
};

next-env.d.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
/// <reference types="next" />
22
/// <reference types="next/image-types/global" />
3+
import "./dist/types/routes.d.ts";
34

45
// NOTE: This file should not be edited
56
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.

0 commit comments

Comments
 (0)