Vibe coding with AI course Β· Module 14: Pinterest Clone

Boards and Pin Saving

4 min read
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. 1. What are boards in Pinterest?

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

Useful articles