Cours Vibe coding avec l'IA · Module 14: Clone de Pinterest
Grille en maçonnerie et découverte de pins
Dans cette leçon5
Bienvenue ! Notre dernière escale - la Treasure Map ! Une plateforme de découverte visuelle de trésors !
Ce sera comme Pinterest - grille en maçonnerie, pins, tableaux, recherche visuelle.
Pinterest vs Instagram/Twitter
Instagram :
- Photos carrées/verticales
- Fil basé sur les abonnements
- Stories + Reels
Twitter :
- Le texte d'abord
- Timeline chronologique
- Mises à jour courtes
Pinterest :
- Découverte visuelle (pas de réseau social)
- Grille en maçonnerie (tailles différentes)
- Tableaux (collections de pins)
- Recherche visuelle (recherche par image)
- Intégration du shopping
- Pas de focus sur les likes/commentaires - l'enregistrement est la clé
- Inspiration et planification (mariages, recettes, décoration intérieure)
Structure d'un pin
1interface Pin {
2 id: string;
3 author: User;
4
5 // Content
6 imageUrl: string;
7 title: string;
8 description: string;
9 link?: string; // lien externe (recette, produit, article)
10
11 // Metadata
12 width: number; // dimensions d'origine de l'image
13 height: number;
14 dominantColor: string; // pour le placeholder
15 boardId?: string;
16 board?: Board;
17
18 // Engagement
19 saves: number; // métrique clé (pas les likes !)
20 comments: Comment[];
21
22 // Discovery
23 tags: string[];
24 relatedPins: Pin[];
25
26 createdAt: Date;
27}
28
29interface Board {
30 id: string;
31 userId: string;
32 user: User;
33
34 // Info
35 name: string;
36 description?: string;
37 coverPinId?: string; // image principale du pin
38
39 // Content
40 pins: Pin[];
41 sections?: BoardSection[]; // organiser les pins dans le tableau
42
43 // Privacy
44 isPrivate: boolean;
45 collaborators?: string[]; // utilisateurs pouvant ajouter des pins
46
47 createdAt: Date;
48 updatedAt: Date;
49}
50
51interface BoardSection {
52 id: string;
53 name: string;
54 pins: Pin[];
55}Disposition en grille de maçonnerie
1'use client';
2
3import { useState, useEffect, useRef } from 'react';
4
5interface MasonryGridProps {
6 pins: Pin[];
7 columns?: number;
8 gap?: number;
9}
10
11export default function MasonryGrid({
12 pins,
13 columns = 4,
14 gap = 16
15}: MasonryGridProps) {
16 const [columnHeights, setColumnHeights] = useState<number[]>(
17 Array(columns).fill(0)
18 );
19
20 // Répartir les pins entre les colonnes
21 const distributePins = () => {
22 const columnArrays: Pin[][] = Array.from({ length: columns }, () => []);
23 const heights = Array(columns).fill(0);
24
25 pins.forEach(pin => {
26 // Trouver la colonne avec la hauteur minimale
27 const minHeight = Math.min(...heights);
28 const columnIndex = heights.indexOf(minHeight);
29
30 // Ajouter le pin à cette colonne
31 columnArrays[columnIndex].push(pin);
32
33 // Mettre à jour la hauteur de la colonne (approximation)
34 const aspectRatio = pin.height / pin.width;
35 const pinHeight = 300 * aspectRatio; // en supposant une largeur de 300px
36 heights[columnIndex] += pinHeight + gap;
37 });
38
39 setColumnHeights(heights);
40 return columnArrays;
41 };
42
43 const columnArrays = distributePins();
44
45 return (
46 <div
47 className="w-full"
48 style={{
49 display: 'grid',
50 gridTemplateColumns: `repeat(${columns}, 1fr)`,
51 gap: `${gap}px`
52 }}
53 >
54 {columnArrays.map((columnPins, columnIndex) => (
55 <div key={columnIndex} className="flex flex-col" style={{ gap: `${gap}px` }}>
56 {columnPins.map(pin => (
57 <PinCard key={pin.id} pin={pin} />
58 ))}
59 </div>
60 ))}
61 </div>
62 );
63}
64
65function PinCard({ pin }: { pin: Pin }) {
66 const [isHovered, setIsHovered] = useState(false);
67 const [imageLoaded, setImageLoaded] = useState(false);
68
69 return (
70 <div
71 className="relative rounded-2xl overflow-hidden cursor-zoom-in group"
72 onMouseEnter={() => setIsHovered(true)}
73 onMouseLeave={() => setIsHovered(false)}
74 >
75 {/* Placeholder */}
76 {!imageLoaded && (
77 <div
78 className="w-full"
79 style={{
80 paddingBottom: `${(pin.height / pin.width) * 100}%`,
81 backgroundColor: pin.dominantColor
82 }}
83 />
84 )}
85
86 {/* Image */}
87 <img
88 src={pin.imageUrl}
89 alt={pin.title}
90 onLoad={() => setImageLoaded(true)}
91 className={`
92 w-full h-auto block
93 ${!imageLoaded ? 'absolute inset-0' : ''}
94 `}
95 />
96
97 {/* Hover Overlay */}
98 {isHovered && (
99 <div className="absolute inset-0 bg-black/40 p-4 flex flex-col justify-between opacity-0 group-hover:opacity-100 transition">
100 {/* Top Actions */}
101 <div className="flex justify-end">
102 <button className="px-4 py-2 bg-red-600 text-white rounded-full font-semibold hover:bg-red-700">
103 Enregistrer
104 </button>
105 </div>
106
107 {/* Bottom Info */}
108 <div>
109 {pin.title && (
110 <h3 className="text-white font-semibold text-lg mb-2">
111 {pin.title}
112 </h3>
113 )}
114 <div className="flex items-center gap-2">
115 <img
116 src={pin.author.avatar}
117 alt={pin.author.name}
118 className="w-8 h-8 rounded-full"
119 />
120 <span className="text-white text-sm">{pin.author.name}</span>
121 </div>
122 </div>
123 </div>
124 )}
125 </div>
126 );
127}Défilement infini
1'use client';
2
3export default function PinFeed() {
4 const [pins, setPins] = useState<Pin[]>([]);
5 const [page, setPage] = useState(1);
6 const [isLoading, setIsLoading] = useState(false);
7 const [hasMore, setHasMore] = useState(true);
8
9 const loadMorePins = async () => {
10 if (isLoading || !hasMore) return;
11
12 setIsLoading(true);
13 const newPins = await fetchPins(page);
14
15 if (newPins.length === 0) {
16 setHasMore(false);
17 } else {
18 setPins([...pins, ...newPins]);
19 setPage(page + 1);
20 }
21
22 setIsLoading(false);
23 };
24
25 // Intersection Observer pour le défilement infini
26 useEffect(() => {
27 const observer = new IntersectionObserver(
28 (entries) => {
29 if (entries[0].isIntersecting) {
30 loadMorePins();
31 }
32 },
33 { threshold: 0.5 }
34 );
35
36 const sentinel = document.getElementById('scroll-sentinel');
37 if (sentinel) observer.observe(sentinel);
38
39 return () => observer.disconnect();
40 }, [page, isLoading, hasMore]);
41
42 return (
43 <div className="p-8">
44 <MasonryGrid pins={pins} />
45
46 {/* Élément sentinelle */}
47 <div id="scroll-sentinel" className="h-20 flex items-center justify-center">
48 {isLoading && <div className="text-gray-500">Chargement d'autres pins...</div>}
49 </div>
50 </div>
51 );
52}Résumé
Grille en maçonnerie - colonnes dynamiques aux hauteurs variables Carte de pin - image, overlay au survol, bouton d'enregistrement Ratio d'aspect - conserver les proportions d'origine de l'image Répartition en colonnes - équilibrer les hauteurs entre les colonnes Couleur dominante - placeholder pendant le chargement Défilement infini - Intersection Observer Effets au survol - afficher les actions et les infos
Dans le prochain exercice, nous ajouterons les tableaux et l'enregistrement !
À bientôt !
Code de cette leçon : App.tsx
1'use client';
2
3import { useState } from 'react';
4
5interface Pin {
6 id: string;
7 imageUrl: string;
8 title: string;
9 author: {
10 name: string;
11 avatar: string;
12 };
13 width: number;
14 height: number;
15 dominantColor: string;
16}
17
18const mockPins: Pin[] = [
19 {
20 id: '1',
21 imageUrl: 'https://images.unsplash.com/photo-1544551763-46a013bb70d5?w=400',
22 title: 'Navire pirate au coucher du soleil',
23 author: { name: 'Captain Redbeard', avatar: 'https://i.pravatar.cc/150?img=12' },
24 width: 400,
25 height: 600,
26 dominantColor: '#4a5568'
27 },
28 {
29 id: '2',
30 imageUrl: 'https://images.unsplash.com/photo-1517694712202-14dd9538aa97?w=400',
31 title: 'Poste de codage',
32 author: { name: 'Sailor Sam', avatar: 'https://i.pravatar.cc/150?img=33' },
33 width: 400,
34 height: 267,
35 dominantColor: '#2d3748'
36 },
37 {
38 id: '3',
39 imageUrl: 'https://images.unsplash.com/photo-1511379938547-c1f69419868d?w=400',
40 title: 'Production musicale',
41 author: { name: 'Navigator Nancy', avatar: 'https://i.pravatar.cc/150?img=45' },
42 width: 400,
43 height: 500,
44 dominantColor: '#1a202c'
45 }
46];
47
48function MasonryGrid({ pins }: { pins: Pin[] }) {
49 const columns = 3;
50 const gap = 16;
51
52 // Répartir les pins entre les colonnes
53 const columnArrays: Pin[][] = Array.from({ length: columns }, () => []);
54 const heights = Array(columns).fill(0);
55
56 pins.forEach(pin => {
57 const minHeight = Math.min(...heights);
58 const columnIndex = heights.indexOf(minHeight);
59
60 columnArrays[columnIndex].push(pin);
61
62 const aspectRatio = pin.height / pin.width;
63 const pinHeight = 300 * aspectRatio;
64 heights[columnIndex] += pinHeight + gap;
65 });
66
67 return (
68 <div
69 className="w-full"
70 style={{
71 display: 'grid',
72 gridTemplateColumns: `repeat(${columns}, 1fr)`,
73 gap: `${gap}px`
74 }}
75 >
76 {columnArrays.map((columnPins, i) => (
77 <div key={i} className="flex flex-col" style={{ gap: `${gap}px` }}>
78 {columnPins.map(pin => (
79 <PinCard key={pin.id} pin={pin} />
80 ))}
81 </div>
82 ))}
83 </div>
84 );
85}
86
87function PinCard({ pin }: { pin: Pin }) {
88 const [isHovered, setIsHovered] = useState(false);
89 const [imageLoaded, setImageLoaded] = useState(false);
90
91 return (
92 <div
93 className="relative rounded-2xl overflow-hidden cursor-zoom-in group"
94 onMouseEnter={() => setIsHovered(true)}
95 onMouseLeave={() => setIsHovered(false)}
96 >
97 {!imageLoaded && (
98 <div
99 className="w-full"
100 style={{
101 paddingBottom: `${(pin.height / pin.width) * 100}%`,
102 backgroundColor: pin.dominantColor
103 }}
104 />
105 )}
106
107 <img
108 src={pin.imageUrl}
109 alt={pin.title}
110 onLoad={() => setImageLoaded(true)}
111 className="w-full h-auto block"
112 />
113
114 {isHovered && (
115 <div className="absolute inset-0 bg-black/40 p-4 flex flex-col justify-between opacity-0 group-hover:opacity-100 transition">
116 <div className="flex justify-end">
117 <button className="px-4 py-2 bg-red-600 text-white rounded-full font-semibold hover:bg-red-700">
118 Enregistrer
119 </button>
120 </div>
121
122 <div>
123 <h3 className="text-white font-semibold mb-2">{pin.title}</h3>
124 <div className="flex items-center gap-2">
125 <img src={pin.author.avatar} alt={pin.author.name} className="w-8 h-8 rounded-full" />
126 <span className="text-white text-sm">{pin.author.name}</span>
127 </div>
128 </div>
129 </div>
130 )}
131 </div>
132 );
133}
134
135export default function App() {
136 return (
137 <div className="min-h-screen bg-white p-8">
138 <h1 className="text-3xl font-bold mb-8 text-center">Grille en maçonnerie</h1>
139 <MasonryGrid pins={mockPins} />
140 </div>
141 );
142}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. Quelle est la principale différence entre Pinterest et Instagram ?
2. Qu'est-ce que la disposition en maçonnerie ?
Voici 2 des 8 questions de cette leçon. Réponds aux autres dans le jeu.
Tâches pratiques dans le jeu
- Tri horizontal
Ordonne les étapes de la répartition des pins en maçonnerie :
- Tri vertical
Ordonne les étapes du placement d'un pin dans la grille masonry :
- Éditeur de code
Crée une disposition en maçonnerie pour les pins
- Tri horizontal
Ordonne les éléments sur une seule ligne de la balise image du pin :
- Clique dans l'ordre
Clique sur les éléments pour construire l'import du hook React utilisé dans PinCard :
- Éditeur de code
Construis une carte de pin avec des effets au survol
- Tri vertical
Ordonne les étapes de la fonction loadMorePins pendant le défilement infini :
- Clique dans l'ordre
Clique sur les éléments pour construire l'enregistrement de l'élément sentinelle auprès de l'IntersectionObserver :
- Tri horizontal
Ordonne les éléments de la valeur CSS grid pour les colonnes masonry : repeat(columns, 1fr)
- Clique dans l'ordre
Clique sur les éléments pour construire le calcul de la hauteur de colonne minimale :
- Tri vertical
Ordonne les couches de PinCard du bas (arrière-plan) au premier plan :