From b5b931f61c38afb9bb5d6af9cc956ee726cdf133 Mon Sep 17 00:00:00 2001 From: Antoine CARON Date: Sat, 7 Feb 2026 21:03:03 +0100 Subject: [PATCH] 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 --- .gitignore | 5 +- modules/header/Header.tsx | 2 + modules/icons/Search.tsx | 11 ++ modules/search/SearchDialog.module.css | 133 +++++++++++++++ modules/search/SearchDialog.tsx | 211 ++++++++++++++++++++++++ next-env.d.ts | 1 + package.json | 3 +- pnpm-lock.yaml | 9 + scripts/generate-search-index.mjs | 219 +++++++++++++++++++++++++ tsconfig.json | 4 +- 10 files changed, 594 insertions(+), 4 deletions(-) create mode 100644 modules/icons/Search.tsx create mode 100644 modules/search/SearchDialog.module.css create mode 100644 modules/search/SearchDialog.tsx create mode 100644 scripts/generate-search-index.mjs diff --git a/.gitignore b/.gitignore index 7d4c37cd..cb1f2453 100644 --- a/.gitignore +++ b/.gitignore @@ -39,4 +39,7 @@ yarn-error.log* /playwright/.cache/ # IDE -.idea \ No newline at end of file +.idea + +# Generated search index +/public/search-index.json \ No newline at end of file diff --git a/modules/header/Header.tsx b/modules/header/Header.tsx index d6205ecb..fa26e25e 100644 --- a/modules/header/Header.tsx +++ b/modules/header/Header.tsx @@ -4,6 +4,7 @@ import { LogoWithText } from '../icons/LogoWithText'; import { SocialLinks } from './SocialLinks'; import { Navbar } from '../navigation/Navbar'; import { MobileNavigation } from '../navigation/mobile/MobileNavigation'; +import { SearchDialog } from '../search/SearchDialog'; export const Header = () => (
@@ -14,6 +15,7 @@ export const Header = () => ( +
); diff --git a/modules/icons/Search.tsx b/modules/icons/Search.tsx new file mode 100644 index 00000000..68f285e4 --- /dev/null +++ b/modules/icons/Search.tsx @@ -0,0 +1,11 @@ +import React, { FC } from 'react'; +import { IconProps } from './types'; + +export const Search: FC = ({ color = 'currentColor', size = 20 }) => ( + +); diff --git a/modules/search/SearchDialog.module.css b/modules/search/SearchDialog.module.css new file mode 100644 index 00000000..da610b7e --- /dev/null +++ b/modules/search/SearchDialog.module.css @@ -0,0 +1,133 @@ +.searchButton { + display: flex; + align-items: center; + justify-content: center; + gap: 6px; + background: none; + border: none; + cursor: pointer; + color: var(--font-color-default); + padding: 8px; + border-radius: 8px; + transition: color 200ms; +} + +.searchButton:hover { + color: var(--font-color-strong); +} + +@media (max-width: 900px) { + .searchButton { + position: absolute; + right: 55px; + top: 14px; + z-index: 999; + } +} + +.overlay { + position: fixed; + inset: 0; + z-index: 998; + background: rgb(0 0 0 / 0.6); + display: flex; + justify-content: center; + padding-top: min(15vh, 120px); +} + +.container { + width: 100%; + max-width: 600px; + max-height: 70vh; + display: flex; + flex-direction: column; + background: var(--background-page); + border: 1px solid var(--border-light); + border-radius: 12px; + overflow: hidden; + margin: 0 16px; + align-self: flex-start; +} + +.inputWrapper { + display: flex; + align-items: center; + gap: 8px; + padding: 12px 16px; + border-bottom: 1px solid var(--border-light); +} + +.inputIcon { + flex-shrink: 0; + color: var(--font-color-default); +} + +.input { + flex: 1; + background: none; + border: none; + outline: none; + font-size: 16px; + color: var(--font-color-strong); + font-family: inherit; +} + +.input::placeholder { + color: var(--font-color-default); +} + +.results { + overflow-y: auto; + padding: 8px 0; +} + +.resultItem { + display: block; + padding: 12px 16px; + transition: background-color 150ms; + color: inherit; +} + +.resultItem:hover, +.resultItem:focus-visible { + background-color: var(--background-card-hover); + outline: none; +} + +.resultTitle { + font-size: 15px; + font-weight: 500; + color: var(--font-color-strong); +} + +.resultMeta { + font-size: 13px; + color: var(--font-color-default); + margin-top: 4px; + display: flex; + flex-wrap: wrap; + gap: 4px 12px; +} + +.emptyState { + padding: 32px 16px; + text-align: center; + color: var(--font-color-default); + font-size: 14px; +} + +.kbd { + display: none; + font-size: 11px; + padding: 2px 6px; + border: 1px solid var(--border-light); + border-radius: 4px; + color: var(--font-color-default); + font-family: inherit; +} + +@media (min-width: 900px) { + .kbd { + display: inline; + } +} diff --git a/modules/search/SearchDialog.tsx b/modules/search/SearchDialog.tsx new file mode 100644 index 00000000..b86ac3f7 --- /dev/null +++ b/modules/search/SearchDialog.tsx @@ -0,0 +1,211 @@ +'use client'; + +import { useCallback, useEffect, useRef, useState } from 'react'; +import type Fuse from 'fuse.js'; +import Link from 'next/link'; +import { usePathname } from 'next/navigation'; +import { Search } from '../icons/Search'; +import styles from './SearchDialog.module.css'; + +type SearchIndexEntry = { + id: string; + title: string; + dateTime: string; + slug: string; + description?: string; + talks?: Array<{ title: string; speakers?: Array<{ name: string }> }>; + sponsor?: string; +}; + +const dateFormatter = new Intl.DateTimeFormat('fr-FR', { + day: 'numeric', + month: 'long', + year: 'numeric', +}); + +function getSpeakers(entry: SearchIndexEntry) { + if (!entry.talks) return ''; + const speakers = entry.talks.flatMap((t) => t.speakers?.map((s) => s.name) || []); + return speakers.join(', '); +} + +function formatDate(dateTime: string) { + try { + return dateFormatter.format(new Date(dateTime)); + } catch { + return ''; + } +} + +export const SearchDialog = () => { + const [open, setOpen] = useState(false); + const [query, setQuery] = useState(''); + const [results, setResults] = useState([]); + const [index, setIndex] = useState(null); + const [loading, setLoading] = useState(false); + + const fuseRef = useRef | null>(null); + const inputRef = useRef(null); + const overlayRef = useRef(null); + const debounceRef = useRef | undefined>(undefined); + const pathname = usePathname(); + + // Close on route change + useEffect(() => { + setOpen(false); + }, [pathname]); + + // Scroll lock + useEffect(() => { + document.body.toggleAttribute('data-lock-scroll', open); + return () => { + document.body.removeAttribute('data-lock-scroll'); + }; + }, [open]); + + // Keyboard shortcut: Ctrl/Cmd+K to open + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + if ((e.metaKey || e.ctrlKey) && e.key === 'k') { + e.preventDefault(); + setOpen((prev) => !prev); + } + }; + document.addEventListener('keydown', handleKeyDown); + return () => document.removeEventListener('keydown', handleKeyDown); + }, []); + + // Load index + Fuse.js on first open + useEffect(() => { + if (!open || index) return; + + setLoading(true); + Promise.all([ + fetch('/search-index.json').then((r) => r.json() as Promise), + import('fuse.js'), + ]).then(([data, FuseModule]) => { + const Fuse = FuseModule.default; + setIndex(data); + fuseRef.current = new Fuse(data, { + keys: [ + { name: 'title', weight: 2 }, + { name: 'talks.title', weight: 1.5 }, + { name: 'talks.speakers.name', weight: 1.5 }, + { name: 'description', weight: 0.5 }, + { name: 'sponsor', weight: 0.8 }, + ], + threshold: 0.3, + includeScore: true, + }); + setLoading(false); + }); + }, [open, index]); + + // Focus input when dialog opens + useEffect(() => { + if (open) { + requestAnimationFrame(() => inputRef.current?.focus()); + } else { + setQuery(''); + setResults([]); + } + }, [open]); + + // Debounced search + const search = useCallback((value: string) => { + if (debounceRef.current) clearTimeout(debounceRef.current); + debounceRef.current = setTimeout(() => { + if (!fuseRef.current || !value.trim()) { + setResults([]); + return; + } + const fuseResults = fuseRef.current.search(value, { limit: 20 }); + setResults(fuseResults.map((r) => r.item)); + }, 200); + }, []); + + const handleInputChange = (e: React.ChangeEvent) => { + const value = e.target.value; + setQuery(value); + search(value); + }; + + const handleOverlayClick = (e: React.MouseEvent) => { + if (e.target === overlayRef.current) { + setOpen(false); + } + }; + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === 'Escape') { + setOpen(false); + } + }; + + return ( + <> + + + {open && ( +
+
+
+ + +
+ +
+ {loading &&
Chargement...
} + + {!loading && query && results.length === 0 && ( +
Aucun résultat pour « {query} »
+ )} + + {results.map((entry) => { + const speakers = getSpeakers(entry); + return ( + setOpen(false)} + > +
{entry.title}
+
+ {formatDate(entry.dateTime)} + {speakers && {speakers}} + {entry.sponsor && {entry.sponsor}} +
+ + ); + })} + + {!loading && !query && index && ( +
Tapez pour rechercher parmi {index.length} événements
+ )} +
+
+
+ )} + + ); +}; diff --git a/next-env.d.ts b/next-env.d.ts index 1b3be084..5af121e4 100644 --- a/next-env.d.ts +++ b/next-env.d.ts @@ -1,5 +1,6 @@ /// /// +import "./dist/types/routes.d.ts"; // NOTE: This file should not be edited // see https://nextjs.org/docs/app/api-reference/config/typescript for more information. diff --git a/package.json b/package.json index 898d3562..2869723f 100644 --- a/package.json +++ b/package.json @@ -4,7 +4,7 @@ "private": true, "scripts": { "dev": "next dev", - "build": "next build", + "build": "node scripts/generate-search-index.mjs && next build", "start": "next start", "lint": "oxlint", "fmt": "oxfmt --write .", @@ -14,6 +14,7 @@ }, "dependencies": { "@next/mdx": "16.1.6", + "fuse.js": "^7.1.0", "motion": "^12.0.0", "next": "16.1.6", "normalize.css": "8.0.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 95eea0dc..b759343d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -11,6 +11,9 @@ importers: '@next/mdx': specifier: 16.1.6 version: 16.1.6 + fuse.js: + specifier: ^7.1.0 + version: 7.1.0 motion: specifier: ^12.0.0 version: 12.33.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4) @@ -539,6 +542,10 @@ packages: engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} os: [darwin] + fuse.js@7.1.0: + resolution: {integrity: sha512-trLf4SzuuUxfusZADLINj+dE8clK1frKdmqiJNb1Es75fmI5oY6X2mxLVUciLLjxqw/xr72Dhy+lER6dGd02FQ==} + engines: {node: '>=10'} + graphql-request@7.4.0: resolution: {integrity: sha512-xfr+zFb/QYbs4l4ty0dltqiXIp07U6sl+tOKAb0t50/EnQek6CVVBLjETXi+FghElytvgaAWtIOt3EV7zLzIAQ==} peerDependencies: @@ -1195,6 +1202,8 @@ snapshots: fsevents@2.3.2: optional: true + fuse.js@7.1.0: {} + graphql-request@7.4.0(graphql@16.12.0): dependencies: '@graphql-typed-document-node/core': 3.2.0(graphql@16.12.0) diff --git a/scripts/generate-search-index.mjs b/scripts/generate-search-index.mjs new file mode 100644 index 00000000..193bf21d --- /dev/null +++ b/scripts/generate-search-index.mjs @@ -0,0 +1,219 @@ +import { writeFileSync } from 'node:fs'; +import { resolve, dirname } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const MEETUP_GQL_URL = 'https://www.meetup.com/gql2'; +const LYONJS_MEETUP_ID = 18305583; + +const pastEventsQuery = ` + query meetupEvents($id: ID!) { + group(id: $id) { + events(first: 1000, status: PAST, sort: ASC) { + edges { + node { + id + title + description + eventUrl + dateTime + } + } + } + } + } +`; + +const nextEventsQuery = ` + query meetupEvents($id: ID!) { + group(id: $id) { + events { + edges { + node { + id + title + description + eventUrl + dateTime + } + } + } + } + } +`; + +async function fetchFromMeetup(query) { + const response = await fetch(MEETUP_GQL_URL, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ query, variables: { id: LYONJS_MEETUP_ID } }), + }); + + if (!response.ok) { + throw new Error(`Meetup API error: ${response.status} ${response.statusText}`); + } + + return response.json(); +} + +function slugify(messageToSlug) { + const accentChars = 'àáâäæãåāăąçćčđďèéêëēėęěğǵḧîïíīįìıİłḿñńǹňôöòóœøōõőṕŕřßśšşșťțûüùúūǘůűųẃẍÿýžźż·/_,:;'; + const withoutAccent = 'aaaaaaaaaacccddeeeeeeeegghiiiiiiiilmnnnnoooooooooprrsssssttuuuuuuuuuwxyyzzz------'; + const regExpAccent = new RegExp(accentChars.split('').join('|'), 'g'); + + return messageToSlug + .toString() + .toLowerCase() + .replace(/\s+/g, '-') + .replace(regExpAccent, (char) => withoutAccent.charAt(accentChars.indexOf(char))) + .replace(/&/g, '-and-') + .replace(/[^\w-]+/g, '') + .replace(/--+/g, '-') + .replace(/^-+/, '') + .replace(/-+$/, ''); +} + +function slugEventTitle(event) { + return `${slugify(event.title)}-e_${event.id}`; +} + +async function loadDataOverride() { + const __dirname = dirname(fileURLToPath(import.meta.url)); + const dataOverridePath = resolve(__dirname, '../data/data-override.ts'); + + const { readFileSync } = await import('node:fs'); + const content = readFileSync(dataOverridePath, 'utf-8'); + + const overrides = {}; + const entryRegex = /'(https:\/\/www\.meetup\.com\/lyonjs\/events\/[^']+)'/g; + let match; + + while ((match = entryRegex.exec(content)) !== null) { + const url = match[1]; + const entryStart = match.index; + + // Find the matching closing brace for this entry + let braceDepth = 0; + let entryContent = ''; + let started = false; + + for (let i = entryStart; i < content.length; i++) { + if (content[i] === '{') { + if (braceDepth === 0) started = true; + braceDepth++; + } + if (started) entryContent += content[i]; + if (content[i] === '}') { + braceDepth--; + if (braceDepth === 0 && started) break; + } + } + + const override = {}; + + // Extract sponsor name + const sponsorMatch = entryContent.match(/sponsor:\s*(\w+)/); + if (sponsorMatch) { + override.sponsorName = sponsorMatch[1]; + } + + // Extract talks + const talksMatch = entryContent.match(/talks:\s*\[([\s\S]*?)\]\s*(?:,\s*}|,?\s*$|\])/); + if (talksMatch) { + const talksContent = talksMatch[1]; + const talks = []; + + // Match individual talk objects + const talkRegex = /\{[^{}]*(?:\{[^{}]*\}[^{}]*)*\}/g; + let talkMatch; + + while ((talkMatch = talkRegex.exec(talksContent)) !== null) { + const talkStr = talkMatch[0]; + const titleMatch = talkStr.match(/title:\s*['"`]([^'"`]*?)['"`]/); + if (!titleMatch) continue; + + const talk = { title: titleMatch[1] }; + + // Extract speakers + const speakersSection = talkStr.match(/speakers:\s*\[([\s\S]*?)\]/); + if (speakersSection) { + const speakers = []; + const nameRegex = /name:\s*['"`]([^'"`]*?)['"`]/g; + let nameMatch; + while ((nameMatch = nameRegex.exec(speakersSection[1])) !== null) { + speakers.push({ name: nameMatch[1] }); + } + if (speakers.length > 0) talk.speakers = speakers; + } + + talks.push(talk); + } + + if (talks.length > 0) override.talks = talks; + } + + if (Object.keys(override).length > 0) { + overrides[url] = override; + } + } + + // Resolve sponsor names to actual names from sponsors.ts + const sponsorsPath = resolve(__dirname, '../data/sponsors.ts'); + const sponsorsContent = readFileSync(sponsorsPath, 'utf-8'); + const sponsorNames = {}; + const sponsorNameRegex = /export const (\w+):\s*Sponsor\s*=\s*\{[^}]*name:\s*'([^']*)'/g; + let sponsorMatch; + while ((sponsorMatch = sponsorNameRegex.exec(sponsorsContent)) !== null) { + sponsorNames[sponsorMatch[1]] = sponsorMatch[2]; + } + + for (const url of Object.keys(overrides)) { + if (overrides[url].sponsorName) { + overrides[url].sponsorName = sponsorNames[overrides[url].sponsorName] || overrides[url].sponsorName; + } + } + + return overrides; +} + +async function main() { + console.log('Generating search index...'); + + const [pastResult, nextResult, overrides] = await Promise.all([ + fetchFromMeetup(pastEventsQuery), + fetchFromMeetup(nextEventsQuery), + loadDataOverride(), + ]); + + const pastEvents = pastResult?.data?.group?.events?.edges?.map((e) => e.node) || []; + const nextEvents = nextResult?.data?.group?.events?.edges?.map((e) => e.node) || []; + const allEvents = [...pastEvents, ...nextEvents]; + + const index = allEvents.map((event) => { + const override = overrides[event.eventUrl] || {}; + const description = (event.description || '').replace(/<[^>]*>/g, '').slice(0, 200); + + const entry = { + id: event.id, + title: event.title, + dateTime: event.dateTime, + slug: slugEventTitle(event), + description, + }; + + if (override.talks) entry.talks = override.talks; + if (override.sponsorName) entry.sponsor = override.sponsorName; + + return entry; + }); + + const __dirname = dirname(fileURLToPath(import.meta.url)); + const outputPath = resolve(__dirname, '../public/search-index.json'); + writeFileSync(outputPath, JSON.stringify(index)); + + console.log(`Search index generated: ${index.length} events written to ${outputPath}`); +} + +main().catch((err) => { + console.error('Failed to generate search index:', err); + process.exit(1); +}); diff --git a/tsconfig.json b/tsconfig.json index 7d1c55ed..eb8956e5 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -12,7 +12,7 @@ "moduleResolution": "bundler", "resolveJsonModule": true, "isolatedModules": true, - "jsx": "preserve", + "jsx": "react-jsx", "incremental": true, "plugins": [ { @@ -20,6 +20,6 @@ } ] }, - "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", "dist/types/**/*.ts"], + "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", "dist/types/**/*.ts", "dist/dev/types/**/*.ts"], "exclude": ["node_modules"] }