Cours Vibe coding avec l'IA · Module 5: Clone de Reddit
Recherche et filtrage - Trouver du contenu
Dans cette leçon6
Crew Council compte déjà plusieurs centaines de posts. Un pirate se souvient que quelqu'un a parlé d'une carte de Tortuga, mais ne sait plus dans quelle communauté ni quand. Défiler est hors de question : il faut une recherche qu'on peut combiner avec le filtre de communauté et le tri des leçons précédentes.
La recherche côté client
La recherche la plus simple filtre les posts que le navigateur a déjà téléchargés :
1function PostsList() {
2 const [posts, setPosts] = useState<Post[]>([...]); // Tous les posts
3 const [searchQuery, setSearchQuery] = useState("");
4
5 // Filtrage
6 const filteredPosts = posts.filter(post =>
7 post.title.toLowerCase().includes(searchQuery.toLowerCase()) ||
8 post.content.toLowerCase().includes(searchQuery.toLowerCase())
9 );
10
11 return (
12 <div>
13 <input
14 type="text"
15 value={searchQuery}
16 onChange={(e) => setSearchQuery(e.target.value)}
17 placeholder="Rechercher des posts..."
18 className="w-full border rounded px-4 py-2 mb-4"
19 />
20
21 <div className="space-y-4">
22 {filteredPosts.length === 0 ? (
23 <p className="text-gray-500 text-center">Aucun post trouvé</p>
24 ) : (
25 filteredPosts.map(post => <PostCard key={post.id} post={post} />)
26 )}
27 </div>
28 </div>
29 );
30}On passe la requête et le titre en minuscules avec toLowerCase(), donc "Carte" trouve aussi "carte", et includes vérifie si l'expression apparaît dans le texte. La version limitée aux titres est posts.filter(p => p.title.toLowerCase().includes(query.toLowerCase())). Si rien ne correspond, un message remplace la page vide.
Cette solution est simple et rapide, car elle n'envoie aucune requête à l'API, mais elle ne fonctionne que sur les données déjà chargées et ralentit avec des milliers de posts.
Combiner les filtres
Une vraie recherche combine plusieurs conditions dans un ordre fixe :
1function PostsList() {
2 const [posts, setPosts] = useState<Post[]>([...]);
3 const [searchQuery, setSearchQuery] = useState("");
4 const [selectedCommunity, setSelectedCommunity] = useState<string | null>(null);
5 const [sortBy, setSortBy] = useState<"hot" | "new" | "top">("hot");
6
7 // Filtrage + tri
8 const filteredPosts = useMemo(() => {
9 let filtered = posts;
10
11 // 1. Requête de recherche
12 if (searchQuery) {
13 filtered = filtered.filter(post =>
14 post.title.toLowerCase().includes(searchQuery.toLowerCase())
15 );
16 }
17
18 // 2. Filtre par communauté
19 if (selectedCommunity) {
20 filtered = filtered.filter(post => post.community === selectedCommunity);
21 }
22
23 // 3. Tri
24 if (sortBy === "hot") {
25 filtered = [...filtered].sort((a, b) => b.score - a.score);
26 } else if (sortBy === "new") {
27 filtered = [...filtered].sort((a, b) =>
28 new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()
29 );
30 } else if (sortBy === "top") {
31 filtered = [...filtered].sort((a, b) => b.upvotes - a.upvotes);
32 }
33
34 return filtered;
35 }, [posts, searchQuery, selectedCommunity, sortBy]);
36
37 return (
38 <div>
39 {/* Recherche */}
40 <input
41 type="text"
42 value={searchQuery}
43 onChange={(e) => setSearchQuery(e.target.value)}
44 placeholder="Rechercher des posts..."
45 className="w-full border rounded px-4 py-2 mb-4"
46 />
47
48 {/* Filtres */}
49 <div className="flex gap-4 mb-4">
50 {/* Sélection de communauté */}
51 <select
52 value={selectedCommunity || ""}
53 onChange={(e) => setSelectedCommunity(e.target.value || null)}
54 className="border rounded px-3 py-2"
55 >
56 <option value="">Toutes les communautés</option>
57 <option value="pirates">r/pirates</option>
58 <option value="treasure">r/treasure</option>
59 <option value="ships">r/ships</option>
60 </select>
61
62 {/* Boutons de tri */}
63 <div className="flex gap-2">
64 <button
65 onClick={() => setSortBy("hot")}
66 className={`px-3 py-2 rounded ${sortBy === "hot" ? "bg-blue-600 text-white" : "bg-gray-200"}`}
67 >
68 Hot
69 </button>
70 <button
71 onClick={() => setSortBy("new")}
72 className={`px-3 py-2 rounded ${sortBy === "new" ? "bg-blue-600 text-white" : "bg-gray-200"}`}
73 >
74 New
75 </button>
76 <button
77 onClick={() => setSortBy("top")}
78 className={`px-3 py-2 rounded ${sortBy === "top" ? "bg-blue-600 text-white" : "bg-gray-200"}`}
79 >
80 Top
81 </button>
82 </div>
83 </div>
84
85 {/* Résultats */}
86 <div className="space-y-4">
87 {filteredPosts.map(post => <PostCard key={post.id} post={post} />)}
88 </div>
89 </div>
90 );
91}On filtre d'abord par la requête de recherche, puis par la communauté sélectionnée, et enfin on trie le résultat avec le mode hot, new ou top. useMemo met en cache le résultat du filtrage : on ne le recalcule pas à chaque rendu, seulement quand une valeur du tableau de dépendances change. On trie une copie ([...filtered]) pour ne pas modifier le tableau de l'état.
Les filtres dans l'URL
Des filtres enregistrés dans l'adresse permettent d'envoyer à quelqu'un un lien vers les résultats ou d'y revenir avec le bouton Précédent :
1/posts?q=treasure&community=pirates&sort=hotDans Next.js, tu les lis avec le hook useSearchParams, qui ne fonctionne que dans les Client Components, par exemple les fichiers avec 'use client' :
1'use client'; // Composant client !
2
3import { useSearchParams, useRouter } from 'next/navigation';
4
5function PostsList() {
6 const searchParams = useSearchParams();
7 const router = useRouter();
8
9 const query = searchParams.get('q') || "";
10 const community = searchParams.get('community');
11 const sort = searchParams.get('sort') || "hot";
12
13 const updateFilters = (key: string, value: string | null) => {
14 const params = new URLSearchParams(searchParams);
15 if (value) {
16 params.set(key, value);
17 } else {
18 params.delete(key);
19 }
20 router.push(`?${params.toString()}`);
21 };
22
23 return (
24 <div>
25 <input
26 type="text"
27 value={query}
28 onChange={(e) => updateFilters('q', e.target.value || null)}
29 placeholder="Rechercher des posts..."
30 />
31
32 <select
33 value={community || ""}
34 onChange={(e) => updateFilters('community', e.target.value || null)}
35 >
36 <option value="">Toutes les communautés</option>
37 <option value="pirates">r/pirates</option>
38 </select>
39
40 {/* ... */}
41 </div>
42 );
43}searchParams.get('q') renvoie la valeur du paramètre ou null, et updateFilters construit une nouvelle adresse et y navigue avec router.push. Une page statique avec useSearchParams doit être entourée d'un Suspense, sinon le build de production échoue. router.push ajoute une entrée à l'historique à chaque caractère : pour un champ de texte, router.replace ou un délai conviennent mieux.
La recherche côté serveur
Avec de gros volumes, c'est la base de données qui cherche, pas le navigateur. Dans Next.js, tu écris pour cela un Route Handler dans le fichier app/api/search/route.ts :
1import { NextRequest, NextResponse } from 'next/server';
2
3export async function GET(request: NextRequest) {
4 const searchParams = request.nextUrl.searchParams;
5 const query = searchParams.get('q') || "";
6 const community = searchParams.get('community');
7 const sort = searchParams.get('sort') || "hot";
8
9 // Requête à la base de données (p. ex. Prisma)
10 const posts = await prisma.post.findMany({
11 where: {
12 OR: [
13 { title: { contains: query, mode: 'insensitive' } },
14 { content: { contains: query, mode: 'insensitive' } }
15 ],
16 ...(community && { community })
17 },
18 orderBy: sort === "new"
19 ? { createdAt: 'desc' }
20 : { upvotes: 'desc' },
21 take: 50 // Limite
22 });
23
24 return NextResponse.json({ posts });
25}request.nextUrl.searchParams lit les paramètres de l'adresse de la requête, et Prisma cherche l'expression dans le titre et le contenu. mode: 'insensitive' ignore la casse dans PostgreSQL et MongoDB, et take: 50 limite le nombre de résultats.
Le client appelle cet endpoint avec le délai que tu connais de la galerie de trésors :
1function PostsList() {
2 const [posts, setPosts] = useState<Post[]>([]);
3 const [loading, setLoading] = useState(false);
4 const debouncedQuery = useDebounce(searchQuery, 500);
5
6 useEffect(() => {
7 setLoading(true);
8 fetch(`/api/search?q=${debouncedQuery}&community=${community}&sort=${sort}`)
9 .then(res => res.json())
10 .then(data => setPosts(data.posts))
11 .finally(() => setLoading(false));
12 }, [debouncedQuery, community, sort]);
13
14 if (loading) {
15 return <div>Chargement...</div>;
16 }
17
18 return (
19 <div>
20 {posts.map(post => <PostCard key={post.id} post={post} />)}
21 </div>
22 );
23}useDebounce attend 500 ms après le dernier caractère, et seulement ensuite fetch interroge le serveur. Les valeurs que tu mets dans une adresse gagnent à être encodées avec encodeURIComponent ou construites avec URLSearchParams, car un espace ou un & dans la requête casse l'adresse.
Mettre en évidence les résultats
On met en évidence l'expression trouvée avec la balise <mark> :
1function highlightText(text: string, query: string) {
2 if (!query) return text;
3
4 const regex = new RegExp(`(${query})`, 'gi');
5 return text.replace(regex, '<mark>$1</mark>');
6}
7
8function PostCard({ post, searchQuery }: { post: Post; searchQuery: string }) {
9 return (
10 <div>
11 <h2
12 dangerouslySetInnerHTML={{
13 __html: highlightText(post.title, searchQuery)
14 }}
15 />
16 </div>
17 );
18}Attention à deux pièges. Les caractères spéciaux, comme une parenthèse, cassent l'expression régulière : il faut d'abord échapper la requête, dans les navigateurs récents avec RegExp.escape(). Le titre vient d'un utilisateur, donc dangerouslySetInnerHTML ne s'utilise qu'après nettoyage du contenu, sinon quelqu'un glissera un script.
Les suggestions
Pour finir, des suggestions qui apparaissent pendant la saisie :
1function SearchWithSuggestions() {
2 const [query, setQuery] = useState("");
3 const [suggestions, setSuggestions] = useState<string[]>([]);
4 const debouncedQuery = useDebounce(query, 300);
5
6 useEffect(() => {
7 if (debouncedQuery) {
8 fetch(`/api/suggestions?q=${debouncedQuery}`)
9 .then(res => res.json())
10 .then(data => setSuggestions(data.suggestions));
11 } else {
12 setSuggestions([]);
13 }
14 }, [debouncedQuery]);
15
16 return (
17 <div className="relative">
18 <input
19 type="text"
20 value={query}
21 onChange={(e) => setQuery(e.target.value)}
22 placeholder="Rechercher..."
23 className="w-full border rounded px-4 py-2"
24 />
25
26 {suggestions.length > 0 && (
27 <div className="absolute top-full left-0 right-0 bg-white border rounded shadow-lg mt-1">
28 {suggestions.map((suggestion, i) => (
29 <button
30 key={i}
31 onClick={() => {
32 setQuery(suggestion);
33 setSuggestions([]);
34 }}
35 className="w-full text-left px-4 py-2 hover:bg-gray-100"
36 >
37 {suggestion}
38 </button>
39 ))}
40 </div>
41 )}
42 </div>
43 );
44}Les suggestions utilisent useDebounce avec un délai plus court, 300 ms, et cliquer sur une suggestion la place dans le champ et masque la liste.
Conseil du Capitaine Barbe-Rouge : commence par filtrer dans le navigateur, et ne déplace la recherche sur le serveur que lorsque les données deviennent trop nombreuses. Quand tu demandes une recherche à l'IA, précise combien de posts tu attends, car la bonne solution en dépend.
À retenir : la recherche est un filtre de plus sur la même liste, appliqué dans l'ordre requête, communauté, tri. Crew Council réunit maintenant trois systèmes principaux : le vote, les commentaires imbriqués et les communautés, et les downvotes et les commentaires imbriqués sont ce qui distinguait Reddit de Twitter dans la première leçon. L'éditeur ci-dessous contient le Crew Council complet de tout le module : les communautés filtrent les posts avec le même filter, les boutons hot, new et top ne font que changer de mode pour l'instant, et tu construiras la recherche avec filtres dans l'exercice avec éditeur.
Code de cette leçon : App.jsx
1// Clone complet de Reddit
2import { useState } from 'react';
3
4const communities = [
5 { id: 'treasure', name: 'r/TreasureHunting', icon: '' },
6 { id: 'battles', name: 'r/SeaBattles', icon: '' },
7];
8
9function Comment({ comment, depth = 0 }) {
10 const [collapsed, setCollapsed] = useState(false);
11 return (
12 <div className={`${depth > 0 ? 'ml-4 border-l-2 border-gray-300 pl-2' : ''} py-1`}>
13 <div className="flex gap-2 text-sm">
14 <div className="text-xs">{comment.score}</div>
15 <div className="flex-1">
16 {!collapsed ? (
17 <>
18 <div className="flex items-center gap-1 text-xs text-gray-600">
19 <b>{comment.author}</b>
20 {comment.replies?.length > 0 && (
21 <button onClick={() => setCollapsed(true)} className="text-blue-500">[−]</button>
22 )}
23 </div>
24 <p className="text-xs">{comment.text}</p>
25 {comment.replies?.map(r => <Comment key={r.id} comment={r} depth={depth + 1} />)}
26 </>
27 ) : (
28 <button onClick={() => setCollapsed(false)} className="text-xs text-blue-500">
29 [+] {comment.author} [{comment.replies.length} réponses]
30 </button>
31 )}
32 </div>
33 </div>
34 </div>
35 );
36}
37
38export default function App() {
39 const [posts] = useState([
40 {
41 id: 1,
42 community: 'treasure',
43 title: 'La meilleure carte au trésor !',
44 upvotes: 156,
45 downvotes: 12,
46 comments: [
47 { id: 1, author: 'Morgan', text: 'Waouh !', score: 23, replies: [
48 { id: 2, author: 'Polly', text: 'Je suis d\'accord !', score: 5, replies: [] }
49 ]}
50 ]
51 },
52 { id: 2, community: 'battles', title: 'Tactiques navales', upvotes: 89, downvotes: 5, comments: [] },
53 ]);
54
55 const [sortMode, setSortMode] = useState('hot');
56 const [selectedCommunity, setSelectedCommunity] = useState(null);
57
58 const filtered = selectedCommunity ? posts.filter(p => p.community === selectedCommunity) : posts;
59
60 return (
61 <div className="min-h-screen bg-gray-100 p-4">
62 <h1 className="text-2xl font-bold mb-4">Crew Council</h1>
63 <div className="flex gap-4">
64 <aside className="w-48">
65 <div className="bg-white rounded-lg shadow p-3 text-sm">
66 <button onClick={() => setSelectedCommunity(null)} className="w-full text-left p-2 hover:bg-gray-100 rounded">Toutes</button>
67 {communities.map(c => (
68 <button key={c.id} onClick={() => setSelectedCommunity(c.id)} className="w-full text-left p-2 hover:bg-gray-100 rounded">
69 {c.icon} {c.name}
70 </button>
71 ))}
72 </div>
73 </aside>
74 <main className="flex-1 max-w-2xl">
75 <div className="flex gap-2 mb-3 bg-white rounded p-2">
76 {['hot', 'new', 'top'].map(m => (
77 <button key={m} onClick={() => setSortMode(m)} className={`px-3 py-1 rounded text-sm ${sortMode === m ? 'bg-orange-500 text-white' : ''}`}>
78 {m}
79 </button>
80 ))}
81 </div>
82 {filtered.map(post => (
83 <div key={post.id} className="bg-white rounded-lg shadow mb-3 p-4">
84 <div className="flex gap-3">
85 <div className="flex flex-col items-center text-xs">
86 <div>▲</div>
87 <div className="font-bold">{post.upvotes - post.downvotes}</div>
88 <div>▼</div>
89 </div>
90 <div className="flex-1">
91 <div className="text-xs text-gray-500">
92 {communities.find(c => c.id === post.community)?.icon} {communities.find(c => c.id === post.community)?.name}
93 </div>
94 <h2 className="font-bold mb-2">{post.title}</h2>
95 <div className="text-xs text-gray-500 mb-2">{post.comments.length} commentaires</div>
96 {post.comments.map(c => <Comment key={c.id} comment={c} />)}
97 </div>
98 </div>
99 </div>
100 ))}
101 </main>
102 </div>
103 </div>
104 );
105}Tu as repéré une erreur dans cette leçon ?
Teste tes connaissances
Réponds aux questions de cette leçon. Choisis une réponse pour voir tout de suite si elle est correcte.
1. Comment filtres-tu les posts par titre côté client ?
2. À quoi sert useMemo lors du filtrage d'une liste de posts ?
Voici 2 des 4 questions de cette leçon. Réponds aux autres dans le jeu.
Tâches pratiques dans le jeu
- Éditeur de code
Rechercher et filtrer des posts
- Tri vertical
Ordonne les étapes de combinaison des filtres de posts comme dans la leçon (recherche → communauté → tri) :