Używamy cookies, żeby zwiększyć Twoje doświadczenia na stronie
CodeWorlds

Masonry Grid i Pin Discovery

Witaj @name! 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

1interface Pin {
2  id: string;
3  author: User;
4
5  // Content
6  imageUrl: string;
7  title: string;
8  description: string;
9  link?: string; // external link (recipe, product, article)
10
11  // Metadata
12  width: number; // original image dimensions
13  height: number;
14  dominantColor: string; // for placeholder
15  boardId?: string;
16  board?: Board;
17
18  // Engagement
19  saves: number; // key metric (not likes!)
20  comments: Comment[];
21
22  // Discovery
23  tags: string[];
24  relatedPins: Pin[];
25
26  createdAt: Date;
27}
28
29interface Board {
30  id: string;
31  userId: string;
32  user: User;
33
34  // Info
35  name: string;
36  description?: string;
37  coverPinId?: string; // main pin image
38
39  // Content
40  pins: Pin[];
41  sections?: BoardSection[]; // organize pins within board
42
43  // Privacy
44  isPrivate: boolean;
45  collaborators?: string[]; // users who can add pins
46
47  createdAt: Date;
48  updatedAt: Date;
49}
50
51interface BoardSection {
52  id: string;
53  name: string;
54  pins: Pin[];
55}

Masonry Grid Layout

1'use client';
2
3import { useState, useEffect, useRef } from 'react';
4
5interface MasonryGridProps {
6  pins: Pin[];
7  columns?: number;
8  gap?: number;
9}
10
11export default function MasonryGrid({
12  pins,
13  columns = 4,
14  gap = 16
15}: MasonryGridProps) {
16  const [columnHeights, setColumnHeights] = useState<number[]>(
17    Array(columns).fill(0)
18  );
19
20  // Distribute pins across columns
21  const distributePins = () => {
22    const columnArrays: Pin[][] = Array.from({ length: columns }, () => []);
23    const heights = Array(columns).fill(0);
24
25    pins.forEach(pin => {
26      // Find column with minimum height
27      const minHeight = Math.min(...heights);
28      const columnIndex = heights.indexOf(minHeight);
29
30      // Add pin to that column
31      columnArrays[columnIndex].push(pin);
32
33      // Update column height (approximate)
34      const aspectRatio = pin.height / pin.width;
35      const pinHeight = 300 * aspectRatio; // assuming 300px width
36      heights[columnIndex] += pinHeight + gap;
37    });
38
39    setColumnHeights(heights);
40    return columnArrays;
41  };
42
43  const columnArrays = distributePins();
44
45  return (
46    <div
47      className="w-full"
48      style={{
49        display: 'grid',
50        gridTemplateColumns: `repeat(${columns}, 1fr)`,
51        gap: `${gap}px`
52      }}
53    >
54      {columnArrays.map((columnPins, columnIndex) => (
55        <div key={columnIndex} className="flex flex-col" style={{ gap: `${gap}px` }}>
56          {columnPins.map(pin => (
57            <PinCard key={pin.id} pin={pin} />
58          ))}
59        </div>
60      ))}
61    </div>
62  );
63}
64
65function PinCard({ pin }: { pin: Pin }) {
66  const [isHovered, setIsHovered] = useState(false);
67  const [imageLoaded, setImageLoaded] = useState(false);
68
69  return (
70    <div
71      className="relative rounded-2xl overflow-hidden cursor-zoom-in group"
72      onMouseEnter={() => setIsHovered(true)}
73      onMouseLeave={() => setIsHovered(false)}
74    >
75      {/* Placeholder */}
76      {!imageLoaded && (
77        <div
78          className="w-full"
79          style={{
80            paddingBottom: `${(pin.height / pin.width) * 100}%`,
81            backgroundColor: pin.dominantColor
82          }}
83        />
84      )}
85
86      {/* Image */}
87      <img
88        src={pin.imageUrl}
89        alt={pin.title}
90        onLoad={() => setImageLoaded(true)}
91        className={`
92          w-full h-auto block
93          ${!imageLoaded ? 'absolute inset-0' : ''}
94        `}
95      />
96
97      {/* Hover Overlay */}
98      {isHovered && (
99        <div className="absolute inset-0 bg-black bg-opacity-40 p-4 flex flex-col justify-between opacity-0 group-hover:opacity-100 transition">
100          {/* Top Actions */}
101          <div className="flex justify-end">
102            <button className="px-4 py-2 bg-red-600 text-white rounded-full font-semibold hover:bg-red-700">
103              Save
104            </button>
105          </div>
106
107          {/* Bottom Info */}
108          <div>
109            {pin.title && (
110              <h3 className="text-white font-semibold text-lg mb-2">
111                {pin.title}
112              </h3>
113            )}
114            <div className="flex items-center gap-2">
115              <img
116                src={pin.author.avatar}
117                alt={pin.author.name}
118                className="w-8 h-8 rounded-full"
119              />
120              <span className="text-white text-sm">{pin.author.name}</span>
121            </div>
122          </div>
123        </div>
124      )}
125    </div>
126  );
127}

Infinite Scroll

1'use client';
2
3export default function PinFeed() {
4  const [pins, setPins] = useState<Pin[]>([]);
5  const [page, setPage] = useState(1);
6  const [isLoading, setIsLoading] = useState(false);
7  const [hasMore, setHasMore] = useState(true);
8
9  const loadMorePins = async () => {
10    if (isLoading || !hasMore) return;
11
12    setIsLoading(true);
13    const newPins = await fetchPins(page);
14
15    if (newPins.length === 0) {
16      setHasMore(false);
17    } else {
18      setPins([...pins, ...newPins]);
19      setPage(page + 1);
20    }
21
22    setIsLoading(false);
23  };
24
25  // Intersection Observer for infinite scroll
26  useEffect(() => {
27    const observer = new IntersectionObserver(
28      (entries) => {
29        if (entries[0].isIntersecting) {
30          loadMorePins();
31        }
32      },
33      { threshold: 0.5 }
34    );
35
36    const sentinel = document.getElementById('scroll-sentinel');
37    if (sentinel) observer.observe(sentinel);
38
39    return () => observer.disconnect();
40  }, [page, isLoading, hasMore]);
41
42  return (
43    <div className="p-8">
44      <MasonryGrid pins={pins} />
45
46      {/* Sentinel element */}
47      <div id="scroll-sentinel" className="h-20 flex items-center justify-center">
48        {isLoading && <div className="text-gray-500">Loading more pins...</div>}
49      </div>
50    </div>
51  );
52}

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! 🚀

Przejdź do CodeWorlds