Vibe coding with AI course Β· Module 14: Pinterest Clone
Boards and Pin Saving
In this lesson4
Pinterest is all about organization through 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="Like 'Places to Go' or 'Recipes to Make'"
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>
);
}Summary
Create board - modal with name and privacy option Save pin - select board with 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
In the next exercise we'll add visual search and shopping!
See you there!
Code for this lesson: 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: 'Beautiful Sunset over the Ocean',
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)}
>
{/* Placeholder */}
{!imageLoaded && (
<div
className="w-full aspect-[2/3]"
style={{ backgroundColor: mockPin.dominantColor }}
/>
)}
{/* Image */}
<img
src={mockPin.imageUrl}
alt={mockPin.title}
onLoad={() => setImageLoaded(true)}
className={`w-full h-auto block ${!imageLoaded ? 'absolute inset-0 object-cover' : ''}`}
/>
{/* Hover Overlay */}
<div
className={`
absolute inset-0 bg-black/40 p-4 flex flex-col justify-between
transition-opacity duration-300
${isHovered ? 'opacity-100' : 'opacity-0'}
`}
>
{/* Save Button */}
<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">
Save
</button>
</div>
{/* Title */}
<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">Hover over the pin to see the overlay!</p>
</div>
</div>
</div>
);
}Spotted a mistake in this lesson?
Check yourself
Answer the questions from this lesson. Pick an answer to see right away whether it is correct.
1. What are boards in Pinterest?
2. What does checking the Keep this board secret option do when creating a board?
These are 2 of 6 questions for this lesson. Solve the rest in the game.
Hands-on tasks in the game
- Horizontal ordering
Arrange the steps of creating a board:
- Click in order
Click the elements to build the privacy state declaration in CreateBoardModal:
- Code editor
Create a modal for creating a board
- Vertical ordering
Order the steps of saving a pin via SavePinModal:
- Code editor
Create a modal for saving a pin
- Vertical ordering
Order the sections of the board page (BoardPage) from top to bottom:
- Click in order
Click the elements to build the section-change handler on BoardPage:
- Horizontal ordering
Arrange the elements on one line of the checkbox input for board privacy: