Kurs vibe coding z AI · Moduł 14: Pinterest Clone

Masonry Grid i Pin Discovery

4 min czytania
W tej lekcji5

Witaj! Ostatni przystanek - Treasure Map! Wizualna platforma odkrywania skarbów!

To będzie jak Pinterest - masonry grid, pins, boards, visual search.

Pinterest vs Instagram/Twitter

Instagram:

  • Square/vertical photos
  • Following-based feed
  • Stories + Reels

Twitter:

  • Text-first
  • Chronological timeline
  • Short updates

Pinterest:

  • Visual discovery (not social networking)
  • Masonry grid layout (różne rozmiary)
  • Boards (collections of pins)
  • Visual search (search by image)
  • Shopping integration
  • No likes/comments focus - saving is key
  • Inspiration i planning (weddings, recipes, home decor)

Pin Structure

interface Pin {
  id: string;
  author: User;

  // Content
  imageUrl: string;
  title: string;
  description: string;
  link?: string; // external link (recipe, product, article)

  // Metadata
  width: number; // original image dimensions
  height: number;
  dominantColor: string; // for placeholder
  boardId?: string;
  board?: Board;

  // Engagement
  saves: number; // key metric (not likes!)
  comments: Comment[];

  // Discovery
  tags: string[];
  relatedPins: Pin[];

  createdAt: Date;
}

interface Board {
  id: string;
  userId: string;
  user: User;

  // Info
  name: string;
  description?: string;
  coverPinId?: string; // main pin image

  // Content
  pins: Pin[];
  sections?: BoardSection[]; // organize pins within board

  // Privacy
  isPrivate: boolean;
  collaborators?: string[]; // users who can add pins

  createdAt: Date;
  updatedAt: Date;
}

interface BoardSection {
  id: string;
  name: string;
  pins: Pin[];
}

Masonry Grid Layout

'use client';

import { useState, useEffect, useRef } from 'react';

interface MasonryGridProps {
  pins: Pin[];
  columns?: number;
  gap?: number;
}

export default function MasonryGrid({
  pins,
  columns = 4,
  gap = 16
}: MasonryGridProps) {
  const [columnHeights, setColumnHeights] = useState<number[]>(
    Array(columns).fill(0)
  );

  // Distribute pins across columns
  const distributePins = () => {
    const columnArrays: Pin[][] = Array.from({ length: columns }, () => []);
    const heights = Array(columns).fill(0);

    pins.forEach(pin => {
      // Find column with minimum height
      const minHeight = Math.min(...heights);
      const columnIndex = heights.indexOf(minHeight);

      // Add pin to that column
      columnArrays[columnIndex].push(pin);

      // Update column height (approximate)
      const aspectRatio = pin.height / pin.width;
      const pinHeight = 300 * aspectRatio; // assuming 300px width
      heights[columnIndex] += pinHeight + gap;
    });

    setColumnHeights(heights);
    return columnArrays;
  };

  const columnArrays = distributePins();

  return (
    <div
      className="w-full"
      style={{
        display: 'grid',
        gridTemplateColumns: `repeat(${columns}, 1fr)`,
        gap: `${gap}px`
      }}
    >
      {columnArrays.map((columnPins, columnIndex) => (
        <div key={columnIndex} className="flex flex-col" style={{ gap: `${gap}px` }}>
          {columnPins.map(pin => (
            <PinCard key={pin.id} pin={pin} />
          ))}
        </div>
      ))}
    </div>
  );
}

function PinCard({ pin }: { pin: Pin }) {
  const [isHovered, setIsHovered] = useState(false);
  const [imageLoaded, setImageLoaded] = useState(false);

  return (
    <div
      className="relative rounded-2xl overflow-hidden cursor-zoom-in group"
      onMouseEnter={() => setIsHovered(true)}
      onMouseLeave={() => setIsHovered(false)}
    >
      {/* Placeholder */}
      {!imageLoaded && (
        <div
          className="w-full"
          style={{
            paddingBottom: `${(pin.height / pin.width) * 100}%`,
            backgroundColor: pin.dominantColor
          }}
        />
      )}

      {/* Image */}
      <img
        src={pin.imageUrl}
        alt={pin.title}
        onLoad={() => setImageLoaded(true)}
        className={`
          w-full h-auto block
          ${!imageLoaded ? 'absolute inset-0' : ''}
        `}
      />

      {/* Hover Overlay */}
      {isHovered && (
        <div className="absolute inset-0 bg-black/40 p-4 flex flex-col justify-between opacity-0 group-hover:opacity-100 transition">
          {/* Top Actions */}
          <div className="flex justify-end">
            <button className="px-4 py-2 bg-red-600 text-white rounded-full font-semibold hover:bg-red-700">
              Save
            </button>
          </div>

          {/* Bottom Info */}
          <div>
            {pin.title && (
              <h3 className="text-white font-semibold text-lg mb-2">
                {pin.title}
              </h3>
            )}
            <div className="flex items-center gap-2">
              <img
                src={pin.author.avatar}
                alt={pin.author.name}
                className="w-8 h-8 rounded-full"
              />
              <span className="text-white text-sm">{pin.author.name}</span>
            </div>
          </div>
        </div>
      )}
    </div>
  );
}

Infinite Scroll

'use client';

export default function PinFeed() {
  const [pins, setPins] = useState<Pin[]>([]);
  const [page, setPage] = useState(1);
  const [isLoading, setIsLoading] = useState(false);
  const [hasMore, setHasMore] = useState(true);

  const loadMorePins = async () => {
    if (isLoading || !hasMore) return;

    setIsLoading(true);
    const newPins = await fetchPins(page);

    if (newPins.length === 0) {
      setHasMore(false);
    } else {
      setPins([...pins, ...newPins]);
      setPage(page + 1);
    }

    setIsLoading(false);
  };

  // Intersection Observer for infinite scroll
  useEffect(() => {
    const observer = new IntersectionObserver(
      (entries) => {
        if (entries[0].isIntersecting) {
          loadMorePins();
        }
      },
      { threshold: 0.5 }
    );

    const sentinel = document.getElementById('scroll-sentinel');
    if (sentinel) observer.observe(sentinel);

    return () => observer.disconnect();
  }, [page, isLoading, hasMore]);

  return (
    <div className="p-8">
      <MasonryGrid pins={pins} />

      {/* Sentinel element */}
      <div id="scroll-sentinel" className="h-20 flex items-center justify-center">
        {isLoading && <div className="text-gray-500">Loading more pins...</div>}
      </div>
    </div>
  );
}

Podsumowanie

Masonry grid - dynamic columns z różnymi wysokościami Pin card - image, hover overlay, save button Aspect ratio - maintain original image proportions Column distribution - balance heights across columns Dominant color - placeholder podczas ładowania Infinite scroll - Intersection Observer Hover effects - show actions and info

W następnym ćwiczeniu dodamy boards i saving!

Do zobaczenia!

Kod do tej lekcji: App.tsx
'use client';

import { useState } from 'react';

interface Pin {
  id: string;
  imageUrl: string;
  title: string;
  author: {
    name: string;
    avatar: string;
  };
  width: number;
  height: number;
  dominantColor: string;
}

const mockPins: Pin[] = [
  {
    id: '1',
    imageUrl: 'https://images.unsplash.com/photo-1544551763-46a013bb70d5?w=400',
    title: 'Piracki statek o zachodzie słońca',
    author: { name: 'Kapitan Redbeard', avatar: 'https://i.pravatar.cc/150?img=12' },
    width: 400,
    height: 600,
    dominantColor: '#4a5568'
  },
  {
    id: '2',
    imageUrl: 'https://images.unsplash.com/photo-1517694712202-14dd9538aa97?w=400',
    title: 'Stanowisko do kodowania',
    author: { name: 'Marynarz Sam', avatar: 'https://i.pravatar.cc/150?img=33' },
    width: 400,
    height: 267,
    dominantColor: '#2d3748'
  },
  {
    id: '3',
    imageUrl: 'https://images.unsplash.com/photo-1511379938547-c1f69419868d?w=400',
    title: 'Produkcja muzyczna',
    author: { name: 'Nawigatorka Nancy', avatar: 'https://i.pravatar.cc/150?img=45' },
    width: 400,
    height: 500,
    dominantColor: '#1a202c'
  }
];

function MasonryGrid({ pins }: { pins: Pin[] }) {
  const columns = 3;
  const gap = 16;

  // Rozdziel piny między kolumny
  const columnArrays: Pin[][] = Array.from({ length: columns }, () => []);
  const heights = Array(columns).fill(0);

  pins.forEach(pin => {
    const minHeight = Math.min(...heights);
    const columnIndex = heights.indexOf(minHeight);

    columnArrays[columnIndex].push(pin);

    const aspectRatio = pin.height / pin.width;
    const pinHeight = 300 * aspectRatio;
    heights[columnIndex] += pinHeight + gap;
  });

  return (
    <div
      className="w-full"
      style={{
        display: 'grid',
        gridTemplateColumns: `repeat(${columns}, 1fr)`,
        gap: `${gap}px`
      }}
    >
      {columnArrays.map((columnPins, i) => (
        <div key={i} className="flex flex-col" style={{ gap: `${gap}px` }}>
          {columnPins.map(pin => (
            <PinCard key={pin.id} pin={pin} />
          ))}
        </div>
      ))}
    </div>
  );
}

function PinCard({ pin }: { pin: Pin }) {
  const [isHovered, setIsHovered] = useState(false);
  const [imageLoaded, setImageLoaded] = useState(false);

  return (
    <div
      className="relative rounded-2xl overflow-hidden cursor-zoom-in group"
      onMouseEnter={() => setIsHovered(true)}
      onMouseLeave={() => setIsHovered(false)}
    >
      {!imageLoaded && (
        <div
          className="w-full"
          style={{
            paddingBottom: `${(pin.height / pin.width) * 100}%`,
            backgroundColor: pin.dominantColor
          }}
        />
      )}

      <img
        src={pin.imageUrl}
        alt={pin.title}
        onLoad={() => setImageLoaded(true)}
        className="w-full h-auto block"
      />

      {isHovered && (
        <div className="absolute inset-0 bg-black/40 p-4 flex flex-col justify-between opacity-0 group-hover:opacity-100 transition">
          <div className="flex justify-end">
            <button className="px-4 py-2 bg-red-600 text-white rounded-full font-semibold hover:bg-red-700">
              Zapisz
            </button>
          </div>

          <div>
            <h3 className="text-white font-semibold mb-2">{pin.title}</h3>
            <div className="flex items-center gap-2">
              <img src={pin.author.avatar} alt={pin.author.name} className="w-8 h-8 rounded-full" />
              <span className="text-white text-sm">{pin.author.name}</span>
            </div>
          </div>
        </div>
      )}
    </div>
  );
}

export default function App() {
  return (
    <div className="min-h-screen bg-white p-8">
      <h1 className="text-3xl font-bold mb-8 text-center">Siatka masonry</h1>
      <MasonryGrid pins={mockPins} />
    </div>
  );
}

Widzisz błąd w tej lekcji?

Sprawdź się

Odpowiedz na pytania z tej lekcji. Wybierz odpowiedź, a od razu zobaczysz, czy jest poprawna.

  1. 1. Główna różnica Pinterest vs Instagram?

  2. 2. Czym jest masonry layout?

To 2 z 8 pytań do tej lekcji. Pozostałe rozwiążesz w grze.

Zadania praktyczne w grze

  • Układanie w poziomie

    Ułóż kroki dystrybucji pinów w masonry:

  • Układanie w pionie

    Uporządkuj etapy umieszczania jednego pina w masonry grid:

  • Edytor kodu

    Stwórz masonry layout dla pinów

  • Układanie w poziomie

    Ułóż kolejność elementów w jednej linii tagu obrazka pina:

  • Klikanie w kolejności

    Kliknij elementy, aby zbudować import hooka z React używanego w PinCard:

  • Edytor kodu

    Zbuduj kartę pina z hover effects

  • Układanie w pionie

    Uporządkuj etapy funkcji loadMorePins przy infinite scroll:

  • Klikanie w kolejności

    Kliknij elementy, aby zbudować rejestrację elementu sentinel w IntersectionObserver:

  • Układanie w poziomie

    Ułóż elementy wartości CSS grid dla kolumn masonry: repeat(columns, 1fr)

  • Klikanie w kolejności

    Kliknij elementy, aby zbudować obliczenie minimalnej wysokości kolumn:

  • Układanie w pionie

    Uporządkuj warstwy PinCard od dołu (tło) do góry (wierzch):

Przydatne artykuły