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

Boards i Pin Saving

4 min czytania
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. 1. Czym są boards w Pinterest?

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

Przydatne artykuły