Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -39,4 +39,7 @@ yarn-error.log*
/playwright/.cache/

# IDE
.idea
.idea

# Generated search index
/public/search-index.json
2 changes: 2 additions & 0 deletions modules/header/Header.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 = () => (
<header className={styles.header}>
Expand All @@ -14,6 +15,7 @@ export const Header = () => (
<MobileNavigation />

<Navbar className={styles.navbar} />
<SearchDialog />
<SocialLinks className={styles.socialLinks} />
</header>
);
11 changes: 11 additions & 0 deletions modules/icons/Search.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import React, { FC } from 'react';
import { IconProps } from './types';

export const Search: FC<IconProps> = ({ color = 'currentColor', size = 20 }) => (
<svg aria-hidden="true" width={size} height={size} viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
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"
fill={color}
/>
</svg>
);
133 changes: 133 additions & 0 deletions modules/search/SearchDialog.module.css
Original file line number Diff line number Diff line change
@@ -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;
}
}
211 changes: 211 additions & 0 deletions modules/search/SearchDialog.tsx
Original file line number Diff line number Diff line change
@@ -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<SearchIndexEntry[]>([]);
const [index, setIndex] = useState<SearchIndexEntry[] | null>(null);
const [loading, setLoading] = useState(false);

const fuseRef = useRef<Fuse<SearchIndexEntry> | null>(null);
const inputRef = useRef<HTMLInputElement>(null);
const overlayRef = useRef<HTMLDivElement>(null);
const debounceRef = useRef<ReturnType<typeof setTimeout> | 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<SearchIndexEntry[]>),
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<HTMLInputElement>) => {
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 (
<>
<button className={styles.searchButton} onClick={() => setOpen(true)} aria-label="Rechercher" type="button">
<Search size={20} />
<kbd className={styles.kbd}>⌘K</kbd>
</button>

{open && (
<div
className={styles.overlay}
ref={overlayRef}
onClick={handleOverlayClick}
onKeyDown={handleKeyDown}
role="dialog"
aria-label="Rechercher un événement"
aria-modal="true"
>
<div className={styles.container}>
<div className={styles.inputWrapper}>
<Search size={18} className={styles.inputIcon} />
<input
ref={inputRef}
className={styles.input}
type="search"
placeholder="Rechercher un événement, un talk, un speaker..."
value={query}
onChange={handleInputChange}
aria-label="Rechercher"
/>
</div>

<div className={styles.results}>
{loading && <div className={styles.emptyState}>Chargement...</div>}

{!loading && query && results.length === 0 && (
<div className={styles.emptyState}>Aucun résultat pour &laquo; {query} &raquo;</div>
)}

{results.map((entry) => {
const speakers = getSpeakers(entry);
return (
<Link
key={entry.id}
href={`/evenement/${entry.slug}`}
className={styles.resultItem}
onClick={() => setOpen(false)}
>
<div className={styles.resultTitle}>{entry.title}</div>
<div className={styles.resultMeta}>
<span>{formatDate(entry.dateTime)}</span>
{speakers && <span>{speakers}</span>}
{entry.sponsor && <span>{entry.sponsor}</span>}
</div>
</Link>
);
})}

{!loading && !query && index && (
<div className={styles.emptyState}>Tapez pour rechercher parmi {index.length} événements</div>
)}
</div>
</div>
</div>
)}
</>
);
};
1 change: 1 addition & 0 deletions next-env.d.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
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.
Loading
Loading