System komentarzy dla bloga Gatsby z użyciem Firebase

Damian Wróblewski - grudzień 2021

System komentarzy dla bloga Gatsby z użyciem Firebase

Spis treści

  1. Własny system komentarzy - komu to potrzebne?
  2. Frontend - struktura projektu i komponenty
  3. Backend - Firebase
  4. Podsumowanie

Własny system komentarzy - komu to potrzebne?

Tworząc treści w internecie naturalnie chcemy, by nasi odbiorcy byli zaangażowani i mieli możliwość wyrażenia swojej opinii na poruszany przez nas temat. W przypadku bloga najczęściej umożliwia to sekcja komentarzy, która zazwyczaj znajduje się pod danym wpisem. Jeśli chcemy wzbogacić nasz blog o takie rozwiązanie mamy do wyboru dwie opcje:

  • zewnętrzne oprogramowanie (Disqus, Commento itp.),
  • własny system komentarzy

Jak to w świecie IT bywa, każde rozwiązanie ma swoje wady i zalety. Zewnętrzne serwisy są szybkie i proste w implementacji oraz mają wiele ciekawych funkcji, które jeśli chcielibyśmy wdrożyć we własnym systemie, wymagałyby dużych nakładów pracy. Własny system posiada jednak jedną niezaprzeczalną zaletę - sami zarządzamy naszymi danymi, więc nie ma obawy, że wszystkie komentarze do naszych treści przestaną istnieć wraz z serwisem, który nimi zarządzał. Drugą oczywistą zaletą budowania takiego systemu jest szlifowanie naszych developerskich skillów :)

Początkowo, chcąc zaoszczędzić czas, chciałem skorzystać z chyba najpowszechniej stosowanego obecnie systemu jakim jest Disqus. Jest to świetne narzędzie, które pozwala użytkownikom komentować treści m.in. poprzez swoje konta social media jak np. Facebook. Miałem już nawet zaprzęgnięte to rozwiązanie na tym blogu, jednak szybko zrezygnowałem. Powodem były reklamy. Disqus w wersji bezpłatnej umieszcza w naszej sekcji komentarzy brzydkie, źle dopasowane, niezwiązane z treścią reklamy. Możemy co prawda skorzystać z bezpłatnej wersji bez reklam jednak pod warunkiem, że nie będziemy chcieli zarabiać na naszym blogu. Podstawowa wersja bez reklam kosztuje natomiast $11 na miesiąc i ma mocno ograniczone możliwości dostosowania wyglądu, więc może mocno odbiegać stylistycznie od naszego bloga. Postanowiłem więc, że stworzę własny system, który będzie prostym rozwiązaniem, ale w pełni zależnym ode mnie.

Jako bazę danych dla komentarzy wykorzystałem Firebase. Firebase to taki uproszczony backend od Google, który świetnie nadaje się do małych projektów lub prototypów. Jest często wykorzystywany przez frontend developerów, gdyż nie wymaga dużej wiedzy z zakresu backendu, a dostarcza m.in. takie funkcje jak baza danych, system uwierzytelniania, hosting plików, cloud functions czy też analitykę. Wersja bezpłatna zupełnie wystarczy na potrzeby naszych pobocznych projektów.

Przypomnę, że blog oparty jest o generator stron statycznych Gatsby, więc będziemy się poruszać w środowisku Reacta. Treści na bloga pobierane są natomiast z headless CMS Contentful.

W tym wpisie zajmiemy się podstawową wersją sekcji komentarzy z jednym poziomem zagnieżdżeń - użytkownicy mogą dodać komentarz do głównego wątku lub odpowiedzieć na któryś z już się w nim znajdujących. Oczywiście własny system daje nam nieograniczone możliwości rozbudowywania, więc możliwym jest utworzenie systemu moderacji lub też uwierzytelniania użytkowników.

Zaczynamy!

Frontend - struktura projektu i komponenty

Szablon wpisu blogowego

Cały kod dostępny jest na GitHubie.

Na początek zajrzymy do szablonu pojedynczego wpisu blogowego, który Gatsby wykorzystuje do generowania poszczególych stron bloga. Węwnątrz tej templatki umieścimy całą sekcję komentarzy w komponencie Comments:

1const BlogPost = ({ data }) => {
2 const post = data.contentfulBlogPost;
4 const id = data.contentfulBlogPost.contentfulid;
5 const [comments, setComments] = useState([
6 {
7 author: "John",
8 content: "Hello World!",
9 postId: id,
10 parentId: null,
11 timestamp: new Date().getTime(),
12 },
13 ]);
15
16 return (
17 <NavigationProvider>
18 <Layout>
19 <Seo title={`${post.title} | Blog`} lang={post.language} />
20 <Navigation lang={post.language} />
21 <main>
22 <ArticleContent>
23 <header>
24 <PostHeader
25 heading={post.title}
26 paragraph={`${post.author} - ${post.date}`}
27 tag="h1"
28 />
29 </header>
30 <Separator />
31 <FeatureImageWrapper>
32 <GatsbyImage
33 image={post.image.gatsbyImageData}
34 alt={post.title}
35 />
36 </FeatureImageWrapper>
37 <Text>
38 <ToC
39 headings={post.text.childMdx.tableOfContents.items}
40 lang={post.language}
41 />
42 <MDXProvider components={components}>
43 <MDXRenderer>{post.text.childMdx.body}</MDXRenderer>
44 </MDXProvider>
45 </Text>
46 </ArticleContent>
47 <HorizontalLine />
48 <Comments comments={comments} postId={id} />
49 <Footer lang={post.language} />
50 </main>
51 </Layout>
52 </NavigationProvider>
53 );
54};
55
56export const pageQuery = graphql`
57 query BlogPostBySlug($slug: String!, $language: String!) {
58 contentfulBlogPost(slug: { eq: $slug }) {
59 contentfulid
60 author
61 date(formatString: "MMMM YYYY", locale: $language)
62 excerpt
63 image {
64 gatsbyImageData(layout: FULL_WIDTH, quality: 100)
65 }
66 language
67 tags
68 title
69 slug
70 text {
71 childMdx {
72 body
73 tableOfContents
74 }
75 }
76 }
77 }
78`;
79
80export default BlogPost;

Najważniejsze dla nas linie kodu zostały podświetlone. Do głównego komponentu Comments przekazujemy 2 propsy: postId - id wpisu blogowego oraz comments czyli pobrane przez nas z Firebase komentarze dla danego wpisu blogowego w formie tablicy obiektów. id wpisu otrzymujemy z właściwości contentfulid z danych zwróconych z zapytania GraphQL do naszego CMS'a.

Zanim skonfigurujemy Firebase, testowy komentarz umieścimy "na sztywno" w stanie naszego komponentu:

1const BlogPost = ({ data }) => {
2 const post = data.contentfulBlogPost;
3 const id = data.contentfulBlogPost.contentfulid;
4 const [comments, setComments] = useState([{name: "John", content: "Hello World!", pId: null, time: null}]);
5
6 return (
7 <NavigationProvider>
8 <Layout>
9 <Seo title={`${post.title} | Blog`} lang={post.language} />
10 <Navigation lang={post.language} />
11 <main>
12 <ArticleContent>
13 <header>
14 <PostHeader
15 heading={post.title}
16 paragraph={`${post.author} - ${post.date}`}
17 tag="h1"
18 />
19 </header>
20 <Separator />
21 <FeatureImageWrapper>
22 <GatsbyImage
23 image={post.image.gatsbyImageData}
24 alt={post.title}
25 />
26 </FeatureImageWrapper>
27 <Text>
28 <ToC
29 headings={post.text.childMdx.tableOfContents.items}
30 lang={post.language}
31 />
32 <MDXProvider components={components}>
33 <MDXRenderer>{post.text.childMdx.body}</MDXRenderer>
34 </MDXProvider>
35 </Text>
36 </ArticleContent>
37 <Comments comments={comments} postId={id} />
38 <Footer lang={post.language} />
39 </main>
40 </Layout>
41 </NavigationProvider>
42 );
43};

Komponent główny - Comments

Zajrzyjmy teraz do wnętrza komponentu Comments:

1import React from "react";
2
3import CommentForm from "../../molecules/Forms/CommentForm";
4import Comment from "./Comment";
5
6const Comments = ({ comments, postId }) => {
7 return (
8 <CommentsContainer>
9 <h2>Join the discussion</h2>
10 <CommentForm postId={postId} />
11 <CommentList>
12 {comments.length > 0 &&
13 comments
14 .filter((comment) => !comment.parentId)
15 .map((comment) => {
16 let children;
17 if (comment.id) {
18 children = comments.filter((c) => comment.id === c.parentId);
19 }
20 return (
21 <Comment
22 key={comment.id}
23 children={children}
24 comment={comment}
25 postId={postId}
26 />
27 );
28 })}
29 </CommentList>
30 </CommentsContainer>
31 );
32};
33
34export default Comments;

Znajdują się tutaj dwa istotne elementy: komponent formularza CommentForm oraz lista komentarzy CommentList. Formularz dodawania komentarzy w najprostszej postaci postaci posiada 2 pola: imię i treść komentarza oraz podstawowe zabezpieczenie przed botami w postaci inputa honeypot. Do formularza jako props przekazujemy postId, by móc przypisać komentarz do odpowiedniego wpisu. Formularz dostępny na tym blogu stworzony został z wykorzystaniem biblioteki Formik i jego kod dostępny jest tutaj.

Najistotniejsza logika sekcji komentarzy mieści się w CommentList. To tutaj trafia tablica komentarzy przekazana jako props. Są to wszystkie komentarze przypisane poprzez ID do aktualnego wpisu blogowego. Na początek filtrujemy komentarze i wybieramy tylko te, które nie mają określonej właściwości parentId, co oznacza, że są to komentarze najwyższego rzędu - niezagnieżdżone:

comments.filter((comment) => !comment.parentId);

Następnie mapujemy przefiltrowane komentarze najwyższego rzędu i spośród wszystkich komentarzy filtrujemy te, których parentId równy jest id danego komentarza i przypisujemy do zmiennej children.

1.map(comment => {
2 let children;
3 if (comment.id) {
4 children = comments.filter(c => comment.id === c.parentId);
5 }

Następnie do komponentu Comment przekazujemy komentarz "rodzic" jako comment oraz komentarze "dzieci" zagnieżdżone wewnątrz tego komentarza jako children.

1return (
2 <Comment
3 key={comment.id}
4 children={children}
5 comment={comment}
6 postId={postId}
7 />
8 );

Komponent Comment

Przyjrzyjmy się teraz komponentowi Comment:

1const SingleComment = ({ comment }) => {
2 const dateObj = comment.timestamp ? new Date(comment.timestamp) : undefined;
3 const time = dateObj ? getDate(dateObj) : "";
4 const timeString = time ? `${time.date} ${time.month} ${time.year}` : "";
5
6 return (
7 <StyledSingleComment>
8 <div className="flex-container">
9 <img src={userIcon} alt="User avatar" width="50" height="50" />
10 <div className="comment">
11 <p className="author">{comment.author}</p>
12 <time>{timeString}</time>
13 <p>{comment.content}</p>
14 </div>
15 </div>
16 </StyledSingleComment>
17 );
18};
19
20const Comment = ({ comment, children, postId }) => {
21 const [showReplyBox, setShowReplyBox] = useState(false);
22
23 const nestedCommentList = sortBy(children, "timestamp").map(comment => (
24 <CommentContainer child className="comment-reply" key={comment.id}>
25 <SingleComment comment={comment} />
26 </CommentContainer>
27 ));
28
29 return (
30 <CommentContainer>
31 <SingleComment comment={comment} />
32 {children && nestedCommentList}
33
34 <div>
35 {showReplyBox ? (
36 <div>
37 <ReplyButton
38 renderAs="button"
39 label="Cancel Reply"
40 clickHandler={() => setShowReplyBox(false)}
41 animated={false}
42 />
43 <CommentForm parentId={comment.id} postId={postId} />
44 </div>
45 ) : (
46 <ReplyButton
47 renderAs="button"
48 label="Reply"
49 clickHandler={() => setShowReplyBox(true)}
50 animated={false}
51 />
52 )}
53 </div>
54 </CommentContainer>
55 );
56};
57
58export default Comment;

Wewnątrz komponentu znajduje się szablon pojedynczego komentarza SingleComment, do którego jako props przekazujemy dane komentarza potrzebne do wyrenderowania.

Do zmiennej nestedCommentList przypisujemy posortowaną chronologicznie listę zagnieżdżonych komentarzy children, którą mapujemy i w efekcie zwracamy listę zagnieżdżonych komentarzy.

Wewnątrz CommentContainer mamy teraz wszystkie elementy komentarza najwyższego rzędu:

  • komentarz główny w komponencie SingleComment,
  • listę zagnieżdżonych komentarzy nestedCommentList,
  • przycisk otwierający formularz odpowiedzi ReplyButton,
  • formularz odpowiedzi CommentForm.

Comment section

Skoro mamy już gotowy frontend sekcji komentarzy, możemy zająć się konfiguracją Firebase.

Backend - Firebase

Jak już wspominałem na początku wpisu, Firebase to kompleksowa usługa backendowa zapewniająca szereg przydatnych usług dla twórców aplikacji, zarówno webowych jak i mobilnych.Jeśli chcesz lepiej poznać możliwości tego serwisu, zachęcam do sprawdzenia kanału Fireship na YouTube.

Utworzenie projektu

Żeby dodać i skonfigurować nowy projekt przejdź na stronę Firebase i wybierz Get started. Następnie zaloguj się na istniejące konto Google lub utwórz nowe.

Następnie kliknij w "Add project", by utworzyć projekt i wpisz jego nazwę.

firebase-start-project

Po dodaniu nowego projektu możemy przejść do konfiguracji wybranej usługi. W naszym wypadku skorzystamy z Firestore Database. Jest to nieco ulepszona wersja Realtime Database. Różnica między nimi polega na tym, jak dane są przechowywane. W przypadku Realtime Database dane przechowywane są w formacie JSON i sprawdzi się ona w przypadku mniej skomplikowanych struktur danych. W Firestore Database dane przechowywane są w formie kolekcji i dokumentów, co może lepiej się sprawdzić w przypadku skalowania naszej aplikacji.

Po wybraniu w panelu bocznym zakładki Firestore Database wybieramy Create database by utworzyć nową bazę danych.

Firestore create database

W kolejnym kroku będziemy musieli określić tryb zabezpieczeń. Możemy skorzystać z trybu testowego lub wybrać tryb produkcyjny i od razu określić prawa dostępu dla użytkowników. W przypadku, gdy stworzymy na naszym blogu system logowania, możemy tak ustawić prawa dostępu, by dodawać komentarze mogli jedynie zalogowani użytkownicy.

Firebase security rules

Skoro mamy już utworzoną bazę, korzystając z panelu Firebase możemy dodać przykładowy komentarz. Docelowo komentarze będziemy oczywiście dodawać poprzez formularz w naszej aplikacji. Struktura danych w naszym przypadku będzie bardzo prosta: utworzymy kolekcję comments, w której w postaci dokumentów będziemy przechowywać nasze komentarze.

Dokument komentarza będzie miał następujące pola:

  • author - nazwę autora komentarza,
  • content - treść komentarza,
  • parentId - id komentarza nadrzędnego, w przypadku niezagnieżdżonych komentarzy wartość będzie null,
  • postId - id wpisu blogowego, do którego przypięty jest komentarz,
  • timestamp - czas dodania komentarza.

Nasz testowy komentarz powinien prezentować się następująco:

Firestore database example

Dodanie obsługi Firebase do projektu

Nadszedł czas, by dodać Firebase do naszego projektu. Na początek instalujemy odpowiednią paczkę przez package managera. W przypadku npm będzie to:

npm install firebase --save

Następnie musimy utworzyć plik konfiguracyjny firebase.js, który umieścimy w katalogu services. Wewnątrz znajdować się będą konfiguracja i inicjalizacja projektu.

1import { initializeApp } from "firebase/app";
2import { getFirestore } from "firebase/firestore";
3
4let db;
5
6export const firebaseConfig = {
7 apiKey: process.env.GATSBY_FIREBASE_API_KEY,
8 authDomain: process.env.GATSBY_FIREBASE_AUTH_DOMAIN,
9 projectId: process.env.GATSBY_FIREBASE_PROJECT_ID,
10 storageBucket: process.env.GATSBY_FIREBASE_STORAGE_BUCKET,
11 messagingSenderId: process.env.GATSBY_FIREBASE_MESSAGING_SENDER_ID,
12 appId: process.env.GATSBY_FIREBASE_APP_ID,
13 measurementId: process.env.GATSBY_FIREBASE_MEASUREMENT_ID,
14};
15
16initializeApp(firebaseConfig);
17
18if (typeof window !== "undefined") {
19 db = getFirestore();
20}
21
22export { db };

Dane konfiguracyjne przechowujemy w zmiennyś środowiskowych w pliku .env.

Wszystkie informacje konfiguracyjne znajdziemy w ustawieniach projektu klikając na ikonkę koła zębatego:

Firebase settings

W pliku konfiguracyjnym, po inicjalizacji aplikacji znajdziemy warunek, który sprawdza czy obiekt window nie ma wartości undefined. Brak tego warunku może powodować błędy w aplikacjach, których kod uruchamiany jest poza przeglądarką (Node.js). Tak właśnie dzieje się w przypadku Gatsby, dlatego ten warunek jest tutaj koniecznością.

Pobieranie komentarzy z Firestore

Możemy teraz zająć się kodem, który pobierze komentarze z bazy danych. Komentarze pobierać będziemy z poziomu szablonu wpisu blogowego blog-post.js wykorzystując reactowy hook useEffect.

Kod komponentu BlogPost wygląda następująco:

1const BlogPost = ({ data }) => {
2 const post = data.contentfulBlogPost;
3 const id = data.contentfulBlogPost.contentfulid;
4 const [comments, setComments] = useState([]);
5
7 useEffect(() => {
8 const q = query(collection(db, "comments"), where("postId", "==", id));
9
10 const unsubscribe = onSnapshot(q, querySnapshot => {
11 const comments = [];
12
13 querySnapshot.forEach(doc => {
14 comments.push({ id: doc.id, ...doc.data() });
15 });
16
17 setComments(comments);
18 });
19
20 return () => unsubscribe();
21 }, [id]);
23
24 return (
25 <NavigationProvider>
26 <Layout>
27 <Seo title={`${post.title} | Blog`} lang={post.language} />
28 <Navigation lang={post.language} />
29 <main>
30 <ArticleContent>
31 <header>
32 <PostHeader
33 heading={post.title}
34 paragraph={`${post.author} - ${post.date}`}
35 tag="h1"
36 />
37 </header>
38 <Separator />
39 <FeatureImageWrapper>
40 <GatsbyImage
41 image={post.image.gatsbyImageData}
42 alt={post.title}
43 />
44 </FeatureImageWrapper>
45 <Text>
46 <ToC
47 headings={post.text.childMdx.tableOfContents.items}
48 lang={post.language}
49 />
50 <MDXProvider components={components}>
51 <MDXRenderer>{post.text.childMdx.body}</MDXRenderer>
52 </MDXProvider>
53 </Text>
54 </ArticleContent>
55 <HorizontalLine />
56 <Comments comments={comments} postId={id} lang={post.language} />
57 <Footer lang={post.language} />
58 </main>
59 </Layout>
60 </NavigationProvider>
61 );
62};

Zanim przeanalizujemy co dzieje się wewnątrz useEffect, warto na tym etapie zwrócić uwagę, że korzystamy tu z kilku funkcji zaimportowanych z Firebase:

import { collection, onSnapshot, query, where } from "firebase/firestore";

Cały kod szablonu dostępny jest tutaj.

Zajrzyjmy zatem do wnętrza useEffect:

1 useEffect(() => {
2 const q = query(collection(db, "comments"), where("postId", "==", id));
3
4 const unsubscribe = onSnapshot(q, querySnapshot => {
5 const comments = [];
6
7 querySnapshot.forEach(doc => {
8 comments.push({ id: doc.id, ...doc.data() });
9 });
10
11 setComments(comments);
12 });
13
14 return () => unsubscribe();
15 }, [id]);

Na początku tworzymy zapytanie do Firebase, w którym z kolekcji comments wybieramy te dokumenty, których postId jest równe id wpisu blogowego. db to wywołanie funkcji getFirestore() zaimportowane z pliku firebase.js.

1 useEffect(() => {
2 const q = query(collection(db, "comments"), where("postId", "==", id));
3
5 const unsubscribe = onSnapshot(q, querySnapshot => {
6 const comments = [];
7
8 querySnapshot.forEach(doc => {
9 comments.push({ id: doc.id, ...doc.data() });
10 });
11
12 setComments(comments);
13 });
15
16 return () => unsubscribe();
17 }, [id]);

Następnie do zmiennej unsubscribe przypisujemy wywołanie funkcji onSnaphot(), do której jako pierwszy argument przekazujemy nasze zapytanie q, a jako drugi funkcję zwrotną przyj. Funkcja onSnapshot() pozwala na nasłuchiwanie w czasie rzeczwistym zmian w bazie danych Firestore. Kiedy w bazie danych nastąpi zmiana, onSnapshot() wywoła funkcję zwrotną z zaktualizowanym argumentem querySnapshot. A querySnapshot to nic innego jak tablica dokumentów pasujących do naszego zapytania. Dlatego też możemy na niej wywołać metodę forEach i do nowo utworzonej tablicy comments dodać wybrane komentarze wraz z id. Następnie gotową tablicę komentarzy możemy przekazać do stanu komponentu poprzez wywołanie setComments(comments).

1 useEffect(() => {
2 const q = query(collection(db, "comments"), where("postId", "==", id));
3
4 const unsubscribe = onSnapshot(q, querySnapshot => {
5 const comments = [];
6
7 querySnapshot.forEach(doc => {
8 comments.push({ id: doc.id, ...doc.data() });
9 });
10
11 setComments(comments);
12 });
13
14 return () => unsubscribe();
15 }, [id]);

Na koniec powinniśmy zwrócić naszą funkcję w return hooka useEffect, by po odmontowaniu komponentu zakończyć subskrybcję do zewnętrznego źródła danych w celu uniknięcia wycieku pamięci. Do tablicy zależności przekazujemy natomiast id wpisu blogowego.

Pobieranie komentarzy jest gotowe! Tak skonfigurowany hook useEffect() będzie nie tylko pobierał komentarze po zamontowaniu komponentu BlogPost, ale także nasłuchiwał zmian i odświeżał widok sekcji, gdy te zmiany nastąpią.

Dodawanie nowych komentarzy

Komentarze dodajemy poprzez formularz zawarty w komponencie CommentForm. Pełen kod komponentu dostępny jest tutaj. Sytuacja wygląda podobnie jak w przypadku pobierania danych z Firestore. Różnica polega na tym, że tym razem musimy skorzystać z udostępnionej przez Firestore funkcji addDoc(), do której jako pierwszy argument przekazujemy kolekcję, do której chcemy dodać dokument, a jako drugi przesyłamy obiekt z danymi mającymi się znaleźć w nowym dokumencie. W naszym przypadku będą to głównie dane pobrane z formularza i przekazane do funkcji poprzez obiekt values.

1const sendForm = (values, { setSubmitting, resetForm }) => {
2 try {
3 const docRef = await addDoc(collection(db, "comments"), {
4 author: values.author,
5 content: values.content,
6 postId,
7 parentId: parentId || null,
8 timestamp: new Date().getTime(),
9 });
10
11 setSubmitting(false);
12 setSubmitBtn(successSubmitBtnContent);
13 resetForm();
14 setTimeout(clearButton, 2500);
15 } catch (e) {
16 setSubmitting(false);
17 setSubmitBtn(errorSubmitBtnContent);
18 setTimeout(clearButton, 1500);
19 console.warn("Error adding document: ", e);
20 }
21 };

Funkcję sendForm() wywołujemy oczywiście w momencie wysyłki formularza. Jako, że w przypadku tego projektu do budowy formularza użyłem biblioteki Formik, będzie to wyglądało następująco:

<Formik onSubmit={sendForm}>

Podsumowanie

W kilku krokach udało nam się zbudować podstawowy system komentarzy na bloga utworzonego w Gatsby. Choć takie podejście wymaga nieco więcej wysiłku niż skorzystanie z zewnętrznego oprogramowania to jednocześnie jest prostsze niż mogłoby się z początku wydawać. Daje nam ono również nieograniczone możliwości jeśli chodzi o rozbudowę funkcjonalności oraz kontrolę nad gromadzonymi danymi.

Jeśli zauważyłeś błąd lub nieścisłość we wpisie lub znasz inny ciekawy sposób rozwiązania tego problemu, odezwij się do mnie przez social media lub przez formularz na stronie.

Bądź ze mną w kontakcie na Twitterze lub LinkedIn, jeśli interesuje Cię świat aplikacji webowych 😉


Dołącz do dyskusji