Cours Vibe coding avec l'IA · Module 5: Clone de Reddit
Communautés et filtrage - Subreddits
Dans cette leçon6
Crew Council se remplit de posts sur tout : cartes au trésor, conseils pour colmater une coque et récits de batailles s'y mêlent. Qui cherche seulement des cartes doit fouiller toute la liste. Reddit répartit le contenu en communautés, les subreddits : des groupes thématiques où les utilisateurs partagent des posts sur un seul sujet. Leurs noms s'écrivent avec le préfixe r/, par exemple r/TreasureHunting, r/SeaBattles ou r/PirateLife.
Le modèle de communauté
Chaque communauté est un objet avec un identifiant, des noms, une description et des règles. Les champs suivent l'ordre id, name, displayName, description, puis icône, nombre de membres, couleur et règles :
1interface Community {
2 id: string; // p. ex. "treasure-hunting"
3 name: string; // p. ex. "r/TreasureHunting"
4 displayName: string; // p. ex. "Treasure Hunting"
5 description: string;
6 icon: string; // Emoji ou URL
7 members: number;
8 color: string; // Couleur du thème
9 rules: string[];
10}id sert au programme (par exemple treasure-hunting), name est le nom avec le préfixe, et displayName un titre lisible pour les humains. Le champ rules stocke un tableau des règles qui s'appliquent dans la communauté, et color la couleur de sa bannière.
La liste des communautés
On enregistre quatre communautés pirates dans un tableau d'objets :
1const communities: Community[] = [
2 {
3 id: 'treasure-hunting',
4 name: 'r/TreasureHunting',
5 displayName: 'Treasure Hunting',
6 description: 'Découvertes, cartes et trésors de toutes les mers',
7 icon: '',
8 members: 12450,
9 color: '#FFD700',
10 rules: [
11 'Partage de vraies trouvailles',
12 'Respecte les autres chercheurs',
13 'Ne révèle pas les lieux des expéditions en cours',
14 ],
15 },
16 {
17 id: 'sea-battles',
18 name: 'r/SeaBattles',
19 displayName: 'Sea Battles',
20 description: 'Batailles navales, stratégies et tactiques',
21 icon: '',
22 members: 8920,
23 color: '#DC2626',
24 rules: [
25 'Décris les batailles en détail',
26 'Fair-play dans les discussions',
27 'Pas de glorification de la violence',
28 ],
29 },
30 {
31 id: 'pirate-life',
32 name: 'r/PirateLife',
33 displayName: 'Pirate Life',
34 description: 'Vie quotidienne en mer et aventures',
35 icon: '',
36 members: 25670,
37 color: '#000000',
38 rules: [
39 'Sois authentique',
40 'Partage des histoires',
41 'Respecte le code des pirates',
42 ],
43 },
44 {
45 id: 'ship-building',
46 name: 'r/ShipBuilding',
47 displayName: 'Ship Building',
48 description: 'Construction et réparation de navires',
49 icon: '',
50 members: 5430,
51 color: '#1E40AF',
52 rules: [
53 'Partage les plans et schémas',
54 'Aide les débutants',
55 'La sécurité avant tout',
56 ],
57 },
58];De la plus grande à la plus petite : r/PirateLife compte 25670 membres, r/TreasureHunting 12450, r/SeaBattles 8920 et r/ShipBuilding 5430. L'ordre dans le tableau n'a pas d'importance, tu peux les trier à tout moment.
Filtrer les posts
On garde la communauté sélectionnée dans l'état et on en déduit la liste des posts à chaque rendu :
1'use client';
2
3import { useState } from 'react';
4
5export default function Feed() {
6 const [posts, setPosts] = useState<Post[]>(allPosts);
7 const [selectedCommunity, setSelectedCommunity] = useState<string | null>(null);
8
9 const filteredPosts = selectedCommunity
10 ? posts.filter(post => post.community === selectedCommunity)
11 : posts;
12
13 return (
14 <div className="flex gap-4">
15 {/* Barre latérale - Communautés */}
16 <aside className="w-64">
17 <div className="bg-white rounded-lg shadow p-4">
18 <h2 className="font-bold text-lg mb-3">Communautés</h2>
19
20 <button
21 onClick={() => setSelectedCommunity(null)}
22 className={`w-full text-left px-3 py-2 rounded mb-1 ${
23 selectedCommunity === null
24 ? 'bg-gray-100 font-medium'
25 : 'hover:bg-gray-50'
26 }`}
27 >
28 Toutes les communautés
29 </button>
30
31 {communities.map(community => (
32 <button
33 key={community.id}
34 onClick={() => setSelectedCommunity(community.id)}
35 className={`w-full text-left px-3 py-2 rounded mb-1 flex items-center gap-2 ${
36 selectedCommunity === community.id
37 ? 'bg-gray-100 font-medium'
38 : 'hover:bg-gray-50'
39 }`}
40 >
41 <span className="text-xl">{community.icon}</span>
42 <div className="flex-1">
43 <div className="text-sm font-medium">{community.name}</div>
44 <div className="text-xs text-gray-500">
45 {formatMembers(community.members)} membres
46 </div>
47 </div>
48 </button>
49 ))}
50 </div>
51 </aside>
52
53 {/* Feed principal */}
54 <main className="flex-1">
55 {selectedCommunity && (
56 <CommunityHeader
57 community={communities.find(c => c.id === selectedCommunity)!}
58 />
59 )}
60
61 <div className="space-y-3">
62 {filteredPosts.map(post => (
63 <PostCard key={post.id} post={post} />
64 ))}
65 </div>
66 </main>
67 </div>
68 );
69}
70
71function formatMembers(count: number): string {
72 if (count >= 1000000) return `${(count / 1000000).toFixed(1)}M`;
73 if (count >= 1000) return `${(count / 1000).toFixed(1)}K`;
74 return count.toString();
75}posts.filter(post => post.community === selectedCommunity) ne garde que les posts dont la communauté correspond à celle sélectionnée. Quand selectedCommunity vaut null, on affiche les posts de toutes les communautés, et c'est ainsi que le bouton Toutes les communautés réinitialise le filtre. Le filtrage ne supprime aucun post de l'état, il crée seulement un nouveau tableau à afficher.
L'en-tête trouve la communauté sélectionnée avec communities.find(c => c.id === selectedCommunity). find renvoie le premier élément correspondant ou undefined, et le point d'exclamation final dit à TypeScript que l'élément existe forcément.
formatMembers raccourcit les grands nombres : à partir de mille, il divise par 1000, garde un chiffre après la virgule et ajoute K, donc 25670 devient 25.7K et 8920 devient 8.9K. Au-delà d'un million, même principe avec M : le code équivaut à (count / 1000000).toFixed(1) + 'M'.
L'en-tête de la communauté
Une fois la communauté choisie, une bannière avec une description, un bouton pour rejoindre et les règles apparaît au-dessus des posts :
1interface CommunityHeaderProps {
2 community: Community;
3}
4
5export default function CommunityHeader({ community }: CommunityHeaderProps) {
6 const [isJoined, setIsJoined] = useState(false);
7
8 return (
9 <div
10 className="rounded-lg mb-4 overflow-hidden"
11 style={{ backgroundColor: community.color }}
12 >
13 {/* Bannière */}
14 <div className="h-24 bg-gradient-to-r from-black/20 to-transparent" />
15
16 {/* Infos */}
17 <div className="bg-white p-4">
18 <div className="flex items-start gap-4">
19 {/* Icône */}
20 <div className="text-6xl -mt-12 bg-white rounded-full p-2 shadow-lg">
21 {community.icon}
22 </div>
23
24 <div className="flex-1">
25 <h1 className="text-2xl font-bold">{community.displayName}</h1>
26 <p className="text-gray-600 text-sm mb-3">
27 {community.name} • {formatMembers(community.members)} membres
28 </p>
29 <p className="text-gray-700 mb-4">{community.description}</p>
30
31 <button
32 onClick={() => setIsJoined(!isJoined)}
33 className={`px-6 py-2 rounded-full font-bold transition ${
34 isJoined
35 ? 'bg-gray-200 text-gray-700 hover:bg-gray-300'
36 : 'bg-blue-500 text-white hover:bg-blue-600'
37 }`}
38 >
39 {isJoined ? 'Membre' : 'Rejoindre'}
40 </button>
41 </div>
42 </div>
43
44 {/* Règles */}
45 <div className="mt-6 bg-gray-50 rounded-lg p-4">
46 <h3 className="font-bold mb-2">Règles de la communauté</h3>
47 <ol className="list-decimal list-inside space-y-1 text-sm text-gray-700">
48 {community.rules.map((rule, index) => (
49 <li key={index}>{rule}</li>
50 ))}
51 </ol>
52 </div>
53 </div>
54 </div>
55 );
56}L'état isJoined indique si l'utilisateur a rejoint la communauté et fait passer le libellé de Rejoindre à Membre. La couleur de fond vient du champ color, et les règles forment une liste numérotée construite à partir de community.rules.
Un nouveau post dans une communauté
Créer un post se fait en quatre étapes : sélectionner la communauté, écrire le titre, ajouter le contenu, publier le post.
1'use client';
2
3import { useState } from 'react';
4
5export default function CreatePost() {
6 const [selectedCommunity, setSelectedCommunity] = useState('');
7 const [title, setTitle] = useState('');
8 const [content, setContent] = useState('');
9
10 const handleSubmit = (e: React.FormEvent) => {
11 e.preventDefault();
12
13 const newPost: Post = {
14 id: Date.now(),
15 title,
16 content,
17 author: 'Toi',
18 community: selectedCommunity,
19 upvotes: 1,
20 downvotes: 0,
21 userVote: null,
22 comments: 0,
23 timestamp: new Date(),
24 };
25
26 // Ajouter le post...
27 };
28
29 return (
30 <div className="bg-white rounded-lg shadow p-6">
31 <h2 className="text-xl font-bold mb-4">Créer un post</h2>
32
33 <form onSubmit={handleSubmit} className="space-y-4">
34 {/* Sélecteur de communauté */}
35 <div>
36 <label className="block font-medium mb-2">Choisis une communauté</label>
37 <select
38 value={selectedCommunity}
39 onChange={(e) => setSelectedCommunity(e.target.value)}
40 className="w-full p-3 border rounded-lg"
41 required
42 >
43 <option value="">Sélectionne une communauté...</option>
44 {communities.map(community => (
45 <option key={community.id} value={community.id}>
46 {community.icon} {community.name}
47 </option>
48 ))}
49 </select>
50 </div>
51
52 {/* Titre */}
53 <div>
54 <label className="block font-medium mb-2">Titre</label>
55 <input
56 type="text"
57 value={title}
58 onChange={(e) => setTitle(e.target.value)}
59 placeholder="Un titre accrocheur pour ton post..."
60 className="w-full p-3 border rounded-lg"
61 maxLength={300}
62 required
63 />
64 <p className="text-xs text-gray-500 mt-1">
65 {title.length}/300 caractères
66 </p>
67 </div>
68
69 {/* Contenu */}
70 <div>
71 <label className="block font-medium mb-2">Texte (optionnel)</label>
72 <textarea
73 value={content}
74 onChange={(e) => setContent(e.target.value)}
75 placeholder="Partage tes pensées..."
76 className="w-full p-3 border rounded-lg resize-none"
77 rows={8}
78 />
79 </div>
80
81 {/* Envoi */}
82 <div className="flex gap-2">
83 <button
84 type="submit"
85 disabled={!selectedCommunity || !title}
86 className="flex-1 py-3 bg-blue-500 text-white rounded-full font-bold hover:bg-blue-600 disabled:bg-gray-300 disabled:cursor-not-allowed"
87 >
88 Publier
89 </button>
90 <button
91 type="button"
92 className="px-6 py-3 border rounded-full font-bold hover:bg-gray-50"
93 >
94 Enregistrer le brouillon
95 </button>
96 </div>
97 </form>
98 </div>
99 );
100}Le bouton de publication (Publier) est désactivé quand aucune communauté n'est sélectionnée ou que le titre manque : disabled={!selectedCommunity || !title}. Le champ du titre a maxLength={300}, donc le navigateur n'accepte pas plus de 300 caractères, et le compteur en dessous indique combien tu en as utilisé. Le contenu est facultatif.
La barre de recherche
Le dernier élément est un formulaire pour chercher des posts et des communautés :
1'use client';
2
3import { useState } from 'react';
4
5export default function SearchBar() {
6 const [searchQuery, setSearchQuery] = useState('');
7 const [searchIn, setSearchIn] = useState<'posts' | 'communities'>('posts');
8
9 const handleSearch = (e: React.FormEvent) => {
10 e.preventDefault();
11 // Implémentation de la recherche...
12 };
13
14 return (
15 <form onSubmit={handleSearch} className="flex gap-2">
16 <div className="flex-1 relative">
17 <input
18 type="text"
19 value={searchQuery}
20 onChange={(e) => setSearchQuery(e.target.value)}
21 placeholder="Rechercher posts et communautés..."
22 className="w-full pl-10 pr-4 py-2 border rounded-full focus:ring-2 focus:ring-blue-500 outline-none"
23 />
24 <span className="absolute left-3 top-2.5 text-gray-400">⌕</span>
25 </div>
26
27 <select
28 value={searchIn}
29 onChange={(e) => setSearchIn(e.target.value as 'posts' | 'communities')}
30 className="px-4 py-2 border rounded-full"
31 >
32 <option value="posts">Posts</option>
33 <option value="communities">Communautés</option>
34 </select>
35
36 <button
37 type="submit"
38 className="px-6 py-2 bg-blue-500 text-white rounded-full font-bold hover:bg-blue-600"
39 >
40 Rechercher
41 </button>
42 </form>
43 );
44}Pour l'instant, ce n'est qu'un squelette : handleSearch bloque le rechargement de la page, mais ne cherche encore rien. Tu ajouteras la logique de recherche et la combinaison des filtres dans la leçon sur la recherche.
Conseil du Capitaine Barbe-Rouge : garde une seule source de vérité dans l'état : la liste complète des posts et la communauté sélectionnée. Calcule la liste filtrée au rendu, car sa copie dans un état séparé finira tôt ou tard par diverger de l'original.
À retenir : une communauté est un filtre appliqué à la même liste de posts. Crew Council a déjà trois systèmes, construits dans cet ordre : le vote, les commentaires imbriqués et les communautés. Dans l'éditeur ci-dessous, tu as une barre latérale avec quatre communautés : clique sur l'une d'elles pour filtrer les posts et sur Toutes pour revenir à tous, puis fais les exercices.
Code de cette leçon : App.jsx
1// Communautés avec filtrage
2import { useState } from 'react';
3
4const communities = [
5 { id: 'treasure', name: 'r/TreasureHunting', icon: '', members: 12450 },
6 { id: 'battles', name: 'r/SeaBattles', icon: '', members: 8920 },
7 { id: 'life', name: 'r/PirateLife', icon: '', members: 25670 },
8 { id: 'ships', name: 'r/ShipBuilding', icon: '', members: 5430 },
9];
10
11const allPosts = [
12 { id: 1, community: 'treasure', title: 'J\'ai trouvé un trésor !', score: 234 },
13 { id: 2, community: 'battles', title: 'Ma première bataille', score: 89 },
14 { id: 3, community: 'life', title: 'La vie en mer', score: 156 },
15 { id: 4, community: 'treasure', title: 'La meilleure carte', score: 312 },
16 { id: 5, community: 'ships', title: 'Comment réparer un navire ?', score: 67 },
17 { id: 6, community: 'life', title: 'Proverbes de pirates', score: 445 },
18];
19
20export default function App() {
21 const [selected, setSelected] = useState(null);
22
23 const filtered = selected ? allPosts.filter(p => p.community === selected) : allPosts;
24
25 return (
26 <div className="min-h-screen bg-gray-100 p-4">
27 <div className="max-w-6xl mx-auto flex gap-4">
28 <aside className="w-64">
29 <div className="bg-white rounded-lg shadow p-4 sticky top-4">
30 <h2 className="font-bold mb-3">Communautés</h2>
31 <button
32 onClick={() => setSelected(null)}
33 className={`w-full text-left px-3 py-2 rounded mb-1 ${!selected ? 'bg-gray-100 font-medium' : 'hover:bg-gray-50'}`}
34 >
35 Toutes
36 </button>
37 {communities.map(c => (
38 <button
39 key={c.id}
40 onClick={() => setSelected(c.id)}
41 className={`w-full text-left px-3 py-2 rounded mb-1 flex items-center gap-2 ${selected === c.id ? 'bg-gray-100 font-medium' : 'hover:bg-gray-50'}`}
42 >
43 <span className="text-xl">{c.icon}</span>
44 <div className="flex-1 min-w-0">
45 <div className="text-sm font-medium truncate">{c.name}</div>
46 <div className="text-xs text-gray-500">{(c.members / 1000).toFixed(1)}K</div>
47 </div>
48 </button>
49 ))}
50 </div>
51 </aside>
52
53 <main className="flex-1">
54 {selected && (
55 <div className="bg-white rounded-lg shadow mb-4 p-6">
56 <div className="flex items-center gap-3">
57 <div className="text-5xl">{communities.find(c => c.id === selected)?.icon}</div>
58 <div>
59 <h1 className="text-2xl font-bold">{communities.find(c => c.id === selected)?.name}</h1>
60 <p className="text-gray-600">{communities.find(c => c.id === selected)?.members.toLocaleString()} membres</p>
61 </div>
62 </div>
63 </div>
64 )}
65
66 <div className="space-y-2">
67 {filtered.map(post => (
68 <div key={post.id} className="bg-white rounded-lg shadow p-4 flex items-center gap-3">
69 <div className="text-center">
70 <div className="text-xl">▲</div>
71 <div className="font-bold text-orange-500">{post.score}</div>
72 <div className="text-xl">▼</div>
73 </div>
74 <div className="flex-1">
75 <div className="text-xs text-gray-500 mb-1">
76 {communities.find(c => c.id === post.community)?.icon} {communities.find(c => c.id === post.community)?.name}
77 </div>
78 <h3 className="font-bold">{post.title}</h3>
79 </div>
80 </div>
81 ))}
82 </div>
83 </main>
84 </div>
85 </div>
86 );
87}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. Qu'est-ce qu'une communauté (subreddit) ?
2. Comment filtres-tu les posts appartenant à la communauté sélectionnée ?
Voici 2 des 7 questions de cette leçon. Réponds aux autres dans le jeu.
Tâches pratiques dans le jeu
- Clique dans l'ordre
Clique sur les éléments dans l'ordre pour construire le filtre des posts par communauté :
- Éditeur de code
Barre latérale des communautés
- Tri horizontal
Ordonne la condition qui compare la communauté d'un post à celle sélectionnée (sur une seule ligne, de gauche à droite) :
- Éditeur de code
En-tête de communauté
- Tri vertical
Ordonne les champs de l'interface Community comme dans la leçon :
- Tri horizontal
Ordonne les étapes de création d'un post dans une communauté :
- Éditeur de code
Créer un post dans une communauté
- Tri vertical
Ordonne les communautés du plus grand au plus petit nombre de membres (PirateLife 25670, TreasureHunting 12450, SeaBattles 8920, ShipBuilding 5430) :
- Clique dans l'ordre
Clique sur les éléments dans l'ordre pour construire l'expression qui formate les millions de membres :
- Tri horizontal
Ordonne la notation du nom de communauté (sur une seule ligne, de gauche à droite) :
- Tri vertical
Ordonne les étapes de construction du forum Conseil de l'Équipage comme dans le module :
- Clique dans l'ordre
Clique sur les éléments dans l'ordre pour construire la recherche de l'objet communauté sélectionné :
- Tri horizontal
Ordonne l'attribut de limite de caractères du champ titre (sur une seule ligne, de gauche à droite) :