Czas stworzyć interfejs galerii inspirowany Instagramem. Nauczysz się tworzyć grid layouts i modale.
Tailwind CSS ma świetne klasy dla grid layouts.
1<div className="grid grid-cols-3 gap-4">
2 <div className="bg-gray-200 aspect-square">Zdjęcie 1</div>
3 <div className="bg-gray-200 aspect-square">Zdjęcie 2</div>
4 <div className="bg-gray-200 aspect-square">Zdjęcie 3</div>
5 <div className="bg-gray-200 aspect-square">Zdjęcie 4</div>
6 <div className="bg-gray-200 aspect-square">Zdjęcie 5</div>
7 <div className="bg-gray-200 aspect-square">Zdjęcie 6</div>
8</div>Klasy:
grid - aktywuje CSS Gridgrid-cols-3 - 3 kolumnygap-4 - odstęp 16px między elementamiaspect-square - kwadratowy kształt (1:1)1<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-1">
2 {/* Zdjęcia */}
3</div>Zachowanie:
1import Image from 'next/image';
2
3interface Photo {
4 id: number;
5 url: string;
6 caption: string;
7 likes: number;
8}
9
10export default function TreasureGallery({ photos }: { photos: Photo[] }) {
11 return (
12 <div className="max-w-5xl mx-auto p-4">
13 <h1 className="text-2xl font-bold mb-6">⚓ Treasure Gallery</h1>
14
15 <div className="grid grid-cols-3 gap-1">
16 {photos.map((photo) => (
17 <div
18 key={photo.id}
19 className="relative aspect-square group cursor-pointer overflow-hidden"
20 >
21 <Image
22 src={photo.url}
23 alt={photo.caption}
24 fill
25 className="object-cover transition-transform group-hover:scale-110"
26 />
27
28 {/* Overlay przy hover */}
29 <div className="absolute inset-0 bg-black bg-opacity-60 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center">
30 <div className="text-white text-center space-y-2">
31 <p className="text-2xl font-bold">❤️ {photo.likes}</p>
32 <p className="text-sm px-4">{photo.caption}</p>
33 </div>
34 </div>
35 </div>
36 ))}
37 </div>
38 </div>
39 );
40}Modal to okno które pojawia się na wierzchu strony. Użyjemy go do pokazania pełnego zdjęcia.
1'use client';
2
3import { useState } from 'react';
4import Image from 'next/image';
5
6interface Photo {
7 id: number;
8 url: string;
9 caption: string;
10 author: string;
11 likes: number;
12}
13
14export default function Gallery({ photos }: { photos: Photo[] }) {
15 const [selectedPhoto, setSelectedPhoto] = useState<Photo | null>(null);
16
17 return (
18 <>
19 {/* Grid */}
20 <div className="grid grid-cols-3 gap-1">
21 {photos.map((photo) => (
22 <div
23 key={photo.id}
24 onClick={() => setSelectedPhoto(photo)}
25 className="relative aspect-square cursor-pointer"
26 >
27 <Image src={photo.url} alt={photo.caption} fill className="object-cover" />
28 </div>
29 ))}
30 </div>
31
32 {/* Modal */}
33 {selectedPhoto && (
34 <div
35 className="fixed inset-0 bg-black bg-opacity-90 z-50 flex items-center justify-center p-4"
36 onClick={() => setSelectedPhoto(null)}
37 >
38 <div
39 className="bg-white rounded-lg max-w-4xl w-full max-h-[90vh] overflow-hidden"
40 onClick={(e) => e.stopPropagation()} // Prevent closing when clicking inside
41 >
42 {/* Header */}
43 <div className="flex items-center justify-between p-4 border-b">
44 <div className="flex items-center gap-3">
45 <div className="text-2xl">🏴☠️</div>
46 <h3 className="font-bold">{selectedPhoto.author}</h3>
47 </div>
48 <button
49 onClick={() => setSelectedPhoto(null)}
50 className="text-gray-500 hover:text-gray-700 text-2xl"
51 >
52 ✕
53 </button>
54 </div>
55
56 {/* Image */}
57 <div className="relative aspect-square">
58 <Image
59 src={selectedPhoto.url}
60 alt={selectedPhoto.caption}
61 fill
62 className="object-contain"
63 />
64 </div>
65
66 {/* Footer */}
67 <div className="p-4 border-t">
68 <div className="flex items-center gap-4 mb-2">
69 <button className="text-2xl hover:scale-110 transition-transform">
70 ❤️
71 </button>
72 <button className="text-2xl hover:scale-110 transition-transform">
73 💬
74 </button>
75 <button className="text-2xl hover:scale-110 transition-transform">
76 📤
77 </button>
78 </div>
79 <p className="font-bold mb-1">{selectedPhoto.likes} polubień</p>
80 <p className="text-gray-800">
81 <span className="font-bold">{selectedPhoto.author}</span>{' '}
82 {selectedPhoto.caption}
83 </p>
84 </div>
85 </div>
86 </div>
87 )}
88 </>
89 );
90}Dla production używaj bibliotek jak Radix UI lub Headless UI, ale oto prosty portal:
1'use client';
2
3import { useEffect, useState } from 'react';
4import { createPortal } from 'react-dom';
5
6function Modal({
7 isOpen,
8 onClose,
9 children
10}: {
11 isOpen: boolean;
12 onClose: () => void;
13 children: React.ReactNode;
14}) {
15 const [mounted, setMounted] = useState(false);
16
17 useEffect(() => {
18 setMounted(true);
19 return () => setMounted(false);
20 }, []);
21
22 useEffect(() => {
23 if (isOpen) {
24 document.body.style.overflow = 'hidden'; // Blokuj scroll
25 } else {
26 document.body.style.overflow = 'unset';
27 }
28 }, [isOpen]);
29
30 if (!mounted || !isOpen) return null;
31
32 return createPortal(
33 <div
34 className="fixed inset-0 bg-black bg-opacity-90 z-50 flex items-center justify-center p-4"
35 onClick={onClose}
36 >
37 <div onClick={(e) => e.stopPropagation()}>
38 {children}
39 </div>
40 </div>,
41 document.body
42 );
43}
44
45export default Modal;Dodaj obsługę klawiatury:
1'use client';
2
3import { useEffect } from 'react';
4
5function useKeyPress(key: string, callback: () => void) {
6 useEffect(() => {
7 const handler = (e: KeyboardEvent) => {
8 if (e.key === key) {
9 callback();
10 }
11 };
12
13 window.addEventListener('keydown', handler);
14 return () => window.removeEventListener('keydown', handler);
15 }, [key, callback]);
16}
17
18function Modal({ isOpen, onClose, children }: ModalProps) {
19 useKeyPress('Escape', onClose);
20
21 // Reszta kodu...
22}Dodaj strzałki do przełączania między zdjęciami:
1'use client';
2
3import { useState } from 'react';
4
5export default function Gallery({ photos }: { photos: Photo[] }) {
6 const [currentIndex, setCurrentIndex] = useState(0);
7 const [isModalOpen, setIsModalOpen] = useState(false);
8
9 const nextPhoto = () => {
10 setCurrentIndex((prev) => (prev + 1) % photos.length);
11 };
12
13 const prevPhoto = () => {
14 setCurrentIndex((prev) => (prev - 1 + photos.length) % photos.length);
15 };
16
17 const currentPhoto = photos[currentIndex];
18
19 return (
20 <>
21 {/* Grid - kliknięcie otwiera modal z danym indexem */}
22 <div className="grid grid-cols-3 gap-1">
23 {photos.map((photo, index) => (
24 <div
25 key={photo.id}
26 onClick={() => {
27 setCurrentIndex(index);
28 setIsModalOpen(true);
29 }}
30 className="relative aspect-square cursor-pointer"
31 >
32 <Image src={photo.url} alt={photo.caption} fill className="object-cover" />
33 </div>
34 ))}
35 </div>
36
37 {/* Modal z carousel */}
38 {isModalOpen && (
39 <div className="fixed inset-0 bg-black bg-opacity-95 z-50 flex items-center justify-center">
40 <button
41 onClick={prevPhoto}
42 className="absolute left-4 text-white text-4xl hover:scale-110 transition-transform"
43 >
44 ‹
45 </button>
46
47 <div className="max-w-4xl w-full">
48 <div className="relative aspect-square">
49 <Image
50 src={currentPhoto.url}
51 alt={currentPhoto.caption}
52 fill
53 className="object-contain"
54 />
55 </div>
56 <p className="text-white text-center mt-4">{currentPhoto.caption}</p>
57 </div>
58
59 <button
60 onClick={nextPhoto}
61 className="absolute right-4 text-white text-4xl hover:scale-110 transition-transform"
62 >
63 ›
64 </button>
65
66 <button
67 onClick={() => setIsModalOpen(false)}
68 className="absolute top-4 right-4 text-white text-3xl"
69 >
70 ✕
71 </button>
72 </div>
73 )}
74 </>
75 );
76}Dla bardziej zaawansowanego layoutu (różne wysokości):
1<div className="columns-1 sm:columns-2 md:columns-3 lg:columns-4 gap-4 space-y-4">
2 {photos.map((photo) => (
3 <div key={photo.id} className="break-inside-avoid">
4 <Image
5 src={photo.url}
6 alt={photo.caption}
7 width={300}
8 height={Math.random() * 200 + 200} // Różne wysokości
9 className="w-full rounded-lg"
10 />
11 </div>
12 ))}
13</div>✅ Grid Layout -
grid grid-cols-3 gap-4
✅ Responsywny grid - grid-cols-1 md:grid-cols-3
✅ aspect-square - kwadratowe elementy
✅ Modal - overlay z fixed position
✅ e.stopPropagation() - prevent closing modal on inside click
✅ Keyboard handling - Escape to close
✅ Image carousel - prev/next navigation
✅ Hover effects - group-hover:opacity-100
✅ Masonry layout - columns-* dla Pinterest styleW następnym ćwiczeniu dodamy funkcję dodawania zdjęć i komentarze!
Do zobaczenia! 🚀