System komentarzy dla bloga Gatsby z użyciem Firebase
Damian Wróblewski - grudzień 2021
Spis treści
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:
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 ]);1516 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 <PostHeader25 heading={post.title}26 paragraph={`${post.author} - ${post.date}`}27 tag="h1"28 />29 </header>30 <Separator />31 <FeatureImageWrapper>32 <GatsbyImage33 image={post.image.gatsbyImageData}34 alt={post.title}35 />36 </FeatureImageWrapper>37 <Text>38 <ToC39 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};5556export const pageQuery = graphql`57 query BlogPostBySlug($slug: String!, $language: String!) {58 contentfulBlogPost(slug: { eq: $slug }) {59 contentfulid60 author61 date(formatString: "MMMM YYYY", locale: $language)62 excerpt63 image {64 gatsbyImageData(layout: FULL_WIDTH, quality: 100)65 }66 language67 tags68 title69 slug70 text {71 childMdx {72 body73 tableOfContents74 }75 }76 }77 }78`;7980export 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}]);56 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 <PostHeader15 heading={post.title}16 paragraph={`${post.author} - ${post.date}`}17 tag="h1"18 />19 </header>20 <Separator />21 <FeatureImageWrapper>22 <GatsbyImage23 image={post.image.gatsbyImageData}24 alt={post.title}25 />26 </FeatureImageWrapper>27 <Text>28 <ToC29 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";23import CommentForm from "../../molecules/Forms/CommentForm";4import Comment from "./Comment";56const Comments = ({ comments, postId }) => {7 return (8 <CommentsContainer>9 <h2>Join the discussion</h2>10 <CommentForm postId={postId} />11 <CommentList>12 {comments.length > 0 &&13 comments14 .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 <Comment22 key={comment.id}23 children={children}24 comment={comment}25 postId={postId}26 />27 );28 })}29 </CommentList>30 </CommentsContainer>31 );32};3334export 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 <Comment3 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}` : "";56 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};1920const Comment = ({ comment, children, postId }) => {21 const [showReplyBox, setShowReplyBox] = useState(false);2223 const nestedCommentList = sortBy(children, "timestamp").map(comment => (24 <CommentContainer child className="comment-reply" key={comment.id}>25 <SingleComment comment={comment} />26 </CommentContainer>27 ));2829 return (30 <CommentContainer>31 <SingleComment comment={comment} />32 {children && nestedCommentList}3334 <div>35 {showReplyBox ? (36 <div>37 <ReplyButton38 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 <ReplyButton47 renderAs="button"48 label="Reply"49 clickHandler={() => setShowReplyBox(true)}50 animated={false}51 />52 )}53 </div>54 </CommentContainer>55 );56};5758export 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.
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ę.
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.
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.
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:
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";34let db;56export 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};1516initializeApp(firebaseConfig);1718if (typeof window !== "undefined") {19 db = getFirestore();20}2122export { 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:
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([]);57 useEffect(() => {8 const q = query(collection(db, "comments"), where("postId", "==", id));910 const unsubscribe = onSnapshot(q, querySnapshot => {11 const comments = [];1213 querySnapshot.forEach(doc => {14 comments.push({ id: doc.id, ...doc.data() });15 });1617 setComments(comments);18 });1920 return () => unsubscribe();21 }, [id]);2324 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 <PostHeader33 heading={post.title}34 paragraph={`${post.author} - ${post.date}`}35 tag="h1"36 />37 </header>38 <Separator />39 <FeatureImageWrapper>40 <GatsbyImage41 image={post.image.gatsbyImageData}42 alt={post.title}43 />44 </FeatureImageWrapper>45 <Text>46 <ToC47 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));34 const unsubscribe = onSnapshot(q, querySnapshot => {5 const comments = [];67 querySnapshot.forEach(doc => {8 comments.push({ id: doc.id, ...doc.data() });9 });1011 setComments(comments);12 });1314 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));35 const unsubscribe = onSnapshot(q, querySnapshot => {6 const comments = [];78 querySnapshot.forEach(doc => {9 comments.push({ id: doc.id, ...doc.data() });10 });1112 setComments(comments);13 });1516 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));34 const unsubscribe = onSnapshot(q, querySnapshot => {5 const comments = [];67 querySnapshot.forEach(doc => {8 comments.push({ id: doc.id, ...doc.data() });9 });1011 setComments(comments);12 });1314 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 });1011 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