Kurs vibe coding z AI · Moduł 14: Pinterest Clone
Masonry Grid i Pin Discovery
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. Główna różnica Pinterest vs Instagram?
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):