Kurs vibe coding z AI · Moduł 14: Pinterest Clone
Boards i Pin Saving
W tej lekcji4
Pinterest to organizacja przez boards - collections of pins.
Board Management
'use client';
interface CreateBoardModalProps {
onClose: () => void;
onCreate: (name: string, isPrivate: boolean) => void;
}
export default function CreateBoardModal({ onClose, onCreate }: CreateBoardModalProps) {
const [name, setName] = useState('');
const [isPrivate, setIsPrivate] = useState(false);
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (name.trim()) {
onCreate(name, isPrivate);
onClose();
}
};
return (
<div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4">
<div className="bg-white rounded-2xl max-w-md w-full p-6">
<h2 className="text-2xl font-bold mb-6">Create board</h2>
<form onSubmit={handleSubmit}>
<div className="mb-4">
<label className="block text-sm font-semibold mb-2">Name</label>
<input
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="Np. 'Miejsca do odwiedzenia' lub 'Przepisy do zrobienia'"
className="w-full px-4 py-3 border-2 border-gray-200 rounded-2xl outline-none focus:border-blue-500"
autoFocus
/>
</div>
<div className="mb-6">
<label className="flex items-center gap-3 cursor-pointer">
<input
type="checkbox"
checked={isPrivate}
onChange={(e) => setIsPrivate(e.target.checked)}
className="w-5 h-5"
/>
<div>
<div className="font-semibold">Keep this board secret</div>
<div className="text-sm text-gray-600">
Only you can see this board
</div>
</div>
</label>
</div>
<div className="flex gap-3">
<button
type="button"
onClick={onClose}
className="flex-1 py-3 border-2 border-gray-200 rounded-full font-semibold hover:border-gray-300"
>
Cancel
</button>
<button
type="submit"
disabled={!name.trim()}
className="flex-1 py-3 bg-red-600 text-white rounded-full font-semibold hover:bg-red-700 disabled:bg-gray-300"
>
Create
</button>
</div>
</form>
</div>
</div>
);
}Save Pin to Board
'use client';
interface SavePinModalProps {
pin: Pin;
boards: Board[];
onClose: () => void;
onSave: (boardId: string) => void;
onCreateBoard: () => void;
}
export default function SavePinModal({
pin,
boards,
onClose,
onSave,
onCreateBoard
}: SavePinModalProps) {
const [selectedBoardId, setSelectedBoardId] = useState<string | null>(null);
return (
<div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4">
<div className="bg-white rounded-2xl max-w-md w-full max-h-[80vh] flex flex-col">
{/* Header */}
<div className="p-6 border-b border-gray-200">
<div className="flex items-center justify-between">
<h2 className="text-xl font-bold">Save to board</h2>
<button
onClick={onClose}
className="text-gray-500 hover:text-black"
>
×
</button>
</div>
</div>
{/* Pin Preview */}
<div className="p-6 border-b border-gray-200">
<div className="flex gap-3">
<img
src={pin.imageUrl}
alt={pin.title}
className="w-16 h-16 rounded object-cover"
/>
<div className="flex-1">
<h3 className="font-semibold line-clamp-1">{pin.title}</h3>
<p className="text-sm text-gray-600 line-clamp-2">
{pin.description}
</p>
</div>
</div>
</div>
{/* Boards List */}
<div className="flex-1 overflow-y-auto p-6">
<button
onClick={onCreateBoard}
className="w-full p-4 border-2 border-dashed border-gray-300 rounded-2xl hover:border-gray-400 mb-4"
>
<div className="flex items-center gap-3">
<div className="w-12 h-12 bg-gray-200 rounded flex items-center justify-center text-2xl">
+
</div>
<span className="font-semibold">Create board</span>
</div>
</button>
{boards.map(board => (
<button
key={board.id}
onClick={() => setSelectedBoardId(board.id)}
className={`
w-full p-4 rounded-2xl hover:bg-gray-100 mb-2
${selectedBoardId === board.id ? 'bg-gray-100 ring-2 ring-blue-500' : ''}
`}
>
<div className="flex items-center gap-3">
{board.coverPinId ? (
<img
src={board.pins[0]?.imageUrl}
alt={board.name}
className="w-12 h-12 rounded object-cover"
/>
) : (
<div className="w-12 h-12 bg-gray-200 rounded" />
)}
<div className="flex-1 text-left">
<div className="font-semibold">{board.name}</div>
<div className="text-sm text-gray-600">
{board.pins.length} pins
</div>
</div>
{selectedBoardId === board.id && (
<div className="text-blue-500">√</div>
)}
</div>
</button>
))}
</div>
{/* Save Button */}
<div className="p-6 border-t border-gray-200">
<button
onClick={() => selectedBoardId && onSave(selectedBoardId)}
disabled={!selectedBoardId}
className="w-full py-3 bg-red-600 text-white rounded-full font-semibold hover:bg-red-700 disabled:bg-gray-300"
>
Save
</button>
</div>
</div>
</div>
);
}Board Page
'use client';
interface BoardPageProps {
board: Board;
isOwner: boolean;
onEditBoard: () => void;
onDeleteBoard: () => void;
}
export default function BoardPage({
board,
isOwner,
onEditBoard,
onDeleteBoard
}: BoardPageProps) {
const [activeSection, setActiveSection] = useState<string | 'all'>('all');
return (
<div className="min-h-screen bg-white">
<div className="max-w-6xl mx-auto px-8 py-12">
{/* Header */}
<div className="text-center mb-8">
<h1 className="text-5xl font-bold mb-4">{board.name}</h1>
{board.description && (
<p className="text-xl text-gray-600 mb-4">{board.description}</p>
)}
<div className="flex items-center justify-center gap-4 text-sm text-gray-600 mb-6">
<span>{board.pins.length} pins</span>
{board.collaborators && board.collaborators.length > 0 && (
<>
<span>•</span>
<span>{board.collaborators.length} collaborators</span>
</>
)}
</div>
{isOwner && (
<div className="flex items-center justify-center gap-3">
<button
onClick={onEditBoard}
className="px-6 py-2 bg-gray-200 rounded-full font-semibold hover:bg-gray-300"
>
Edit board
</button>
<button
onClick={onDeleteBoard}
className="px-6 py-2 bg-gray-200 rounded-full font-semibold hover:bg-gray-300"
>
Delete
</button>
</div>
)}
</div>
{/* Sections */}
{board.sections && board.sections.length > 0 && (
<div className="flex gap-2 mb-8 overflow-x-auto">
<button
onClick={() => setActiveSection('all')}
className={`
px-4 py-2 rounded-full font-semibold whitespace-nowrap
${activeSection === 'all'
? 'bg-black text-white'
: 'bg-gray-200 hover:bg-gray-300'
}
`}
>
All pins
</button>
{board.sections.map(section => (
<button
key={section.id}
onClick={() => setActiveSection(section.id)}
className={`
px-4 py-2 rounded-full font-semibold whitespace-nowrap
${activeSection === section.id
? 'bg-black text-white'
: 'bg-gray-200 hover:bg-gray-300'
}
`}
>
{section.name} ({section.pins.length})
</button>
))}
</div>
)}
{/* Pins Grid */}
<MasonryGrid
pins={
activeSection === 'all'
? board.pins
: board.sections?.find(s => s.id === activeSection)?.pins || []
}
/>
</div>
</div>
);
}Podsumowanie
Create board - modal z name i privacy option Save pin - select board z preview Board page - pins organized in grid Board sections - organize pins within board Collaborators - multiple users can add pins Private boards - secret collections Edit/delete - board management Cover pin - main image for board
W następnym ćwiczeniu dodamy visual search i shopping!
Do zobaczenia!
Kod do tej lekcji: App.tsx
'use client';
import { useState } from 'react';
interface Pin {
id: string;
imageUrl: string;
title: string;
dominantColor: string;
}
const mockPin: Pin = {
id: '1',
imageUrl: 'https://images.unsplash.com/photo-1544551763-46a013bb70d5?w=400&h=600&fit=crop',
title: 'Piękny zachód słońca nad oceanem',
dominantColor: '#e07856'
};
export default function PinCard() {
const [isHovered, setIsHovered] = useState(false);
const [imageLoaded, setImageLoaded] = useState(false);
return (
<div className="min-h-screen bg-gray-100 flex items-center justify-center p-8">
<div className="w-80">
<div
className="relative rounded-2xl overflow-hidden cursor-zoom-in group shadow-lg"
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
>
{/* Kolor zastępczy */}
{!imageLoaded && (
<div
className="w-full aspect-[2/3]"
style={{ backgroundColor: mockPin.dominantColor }}
/>
)}
{/* Obraz */}
<img
src={mockPin.imageUrl}
alt={mockPin.title}
onLoad={() => setImageLoaded(true)}
className={`w-full h-auto block ${!imageLoaded ? 'absolute inset-0 object-cover' : ''}`}
/>
{/* Nakładka po najechaniu kursorem */}
<div
className={`
absolute inset-0 bg-black/40 p-4 flex flex-col justify-between
transition-opacity duration-300
${isHovered ? 'opacity-100' : 'opacity-0'}
`}
>
{/* Przycisk zapisu */}
<div className="flex justify-end">
<button className="px-6 py-2 bg-red-600 text-white rounded-full font-semibold hover:bg-red-700 transition">
Zapisz
</button>
</div>
{/* Tytuł */}
<div>
<h3 className="text-white font-bold text-lg">{mockPin.title}</h3>
</div>
</div>
</div>
<div className="mt-4 text-center">
<p className="text-sm text-gray-600">Najedź kursorem na pin, aby zobaczyć nakładkę!</p>
</div>
</div>
</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. Czym są boards w Pinterest?
2. Co oznacza zaznaczenie opcji Keep this board secret podczas tworzenia board?
To 2 z 6 pytań do tej lekcji. Pozostałe rozwiążesz w grze.
Zadania praktyczne w grze
- Układanie w poziomie
Ułóż kroki tworzenia board:
- Klikanie w kolejności
Kliknij elementy, aby zbudować deklarację stanu prywatności w CreateBoardModal:
- Edytor kodu
Stwórz modal do tworzenia board
- Układanie w pionie
Uporządkuj kroki zapisania pina przez SavePinModal:
- Edytor kodu
Stwórz modal do zapisywania pina
- Układanie w pionie
Uporządkuj sekcje strony board (BoardPage) od góry do dołu:
- Klikanie w kolejności
Kliknij elementy, aby zbudować handler zmiany sekcji na BoardPage:
- Układanie w poziomie
Ułóż elementy w jednej linii inputa checkbox dla prywatności board: