Welcome @name! Our last stop - Treasure Map! A visual treasure discovery platform! 📍
This will be like Pinterest - masonry grid, pins, boards, visual search.
Instagram:
Twitter:
Pinterest:
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}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}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}âś… Masonry grid - dynamic columns with varying heights âś… Pin card - image, hover overlay, save button âś… Aspect ratio - maintain original image proportions âś… Column distribution - balance heights across columns âś… Dominant color - placeholder while loading âś… Infinite scroll - Intersection Observer âś… Hover effects - show actions and info
In the next exercise we'll add boards and saving!
See you there! 🚀