Cours Vibe coding avec l'IA · Module 14: Clone de Pinterest

Grille en maçonnerie et découverte de pins

4 min de lecture
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. 1. Quelle est la principale différence entre Pinterest et Instagram ?

  2. 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 :

Articles utiles