DEV Community

Cover image for Add a comment section to your React project - Part 2: Use your own server
Tsabary
Tsabary

Posted on • Updated on

Add a comment section to your React project - Part 2: Use your own server

In my previous article I've introduced my new library Replyke, which helps you integrate a fully functioning comment section in your React app - in less than 5 minutes.

Replyke has two configurations. In the most basic configuration, all of your data is stored on Replyke's server. This configuration is easy, quick and great if you don't want to mess with building a back-end for your app.

The second configuration, allows you to pass to Replyke a URL to your API, and have your own server handle all of your data.

In my previous article I've shown you how to implement the first, basic configuration. In this article I want to show you how to implement Replyke using your own server.

If you haven't already, read part 1 in this two-parts article, as I am picking up where part 1 ended.

# Note: in this tutorial I will instruct you to create two models, article & comment, that will get translated into collections with these names.
If you already have collections with these names or even one of them - don't worry. Name these models/collections however you see fit, and I will show you how to implement it either way.

Adding our own API backend

Now that we've finished the basic implementation, all of our data will be stored on Replyke's database. But, what if we want all of our comments data to be stored on our own database with the rest of our data? We can do that very easily. All we have to do is create a few endpoints and inform Replyke about them.

In this guide I assume previous knowledge of working with Node.js, Mongodb, Mongooose and Express.js, so I won't go through the basics of any of those, and will only go over what is necessary to add Replyke to your app.

Models:

For our API, we will need only two Models: Article & Comment.

Article: An article document stores the data for every page in which you have a comment section. It will store data like who liked the content on that page, and how many comments, replies and likes there are for this page. For comment sections on dynamic pages, an article document would be created for every unique item/product/post/etc.

Comment: A Comment document stores all the data we need for each comment or reply in our database. That includes the comment body and all the statistics we need such as likes and replies.

Let's start with the Article Model. In your repo, under the "src" folder, if you haven't already, create a "models" folder. In that folder create an article.ts file, and paste in it the following code.

import mongoose from "mongoose";

mongoose.Promise = global.Promise;
export const articleSchema = new mongoose.Schema({
  article_id: {
    type: String,
    trim: true,
    required: true,
  },
  likes: {
    type: [String],
    default: [],
    required: true,
  },
  likes_count: {
    type: Number,
    default: 0,
    required: true,
  },
  comments_count: {
    type: Number,
    default: 0,
    required: true,
  },
  replies_count: {
    type: Number,
    default: 0,
    required: true,
  },
});

export const Article =
  mongoose.models.Article ?? mongoose.model("Article", articleSchema);
Enter fullscreen mode Exit fullscreen mode

Before heading to our routes, we want to also create an article interface for extra type-check safety.

Under your "src" folder create an "interfaces" folder, and in it create a file called "IArticle.ts".
In the file, paste the following code:

export default interface IArticle {
  _id: string;
  article_id: string;
  likes: string[];
  likes_count: number;
  comments_count: number;
  replies_count: number;
}
Enter fullscreen mode Exit fullscreen mode

Now let's head over to our routes. In your routes folder (commonly src/routes), create a file called "articles.ts".
Our application needs only three routes in this file: fetching an article, liking an article and un-liking an article.

In your "src/routes/articles.ts" file, start by pasting the following code:

import { Router, Request as ExReq, Response as ExRes } from "express";
import { Article } from "../models/article";
import IArticle from "../interfaces/IArticle";

const router = Router();

### THIS IS WHERE OUR ROUTES WOULD COME ###

export default router;
Enter fullscreen mode Exit fullscreen mode

Now let's create our first route. This route handles the fetching of an article. Just above the export statement add the following code.

// Fetch a single article
router.get("/articles", async (req: ExReq, res: ExRes) => {
  try {
    const { article_id, app_key } = req.query;

    const article: IArticle | null = await Article.findOne({
      article_id,
    });

    if (!article) return res.status(204).send();

    return res.status(200).send(article);
  } catch (err: any) {
    return res.status(500).send({ error: err.message });
  }
});
Enter fullscreen mode Exit fullscreen mode

You might've notice we have an unused variable "app_key" that we receive in our params.

When using Replyke in its basic implementation, Replyke needs to not only check for the article id, but to also cross that with the app id, as other apps might also have articles with the same id.
As we now go over how to store our own data, we don't really need the app_key anymore, as we will only be handling data for our own app.
You are welcomed to delete this variable from the route, or do as you wish with it in your database.
As I continue this tutorial I will keep showing "app_key" wherever we get it in our route props, so you would know when it is available, if you wish to do anything with that data.

For our second route, we need to handle when a user wants to like an article. After the previous route, paste the following route. I have added notes along the code to explain what we are doing.

router.post("/articles/like", async (req: ExReq, res: ExRes) => {
  try {
    const body = req.body;

    if (!body) throw new Error("No request body was");

    const { article_id, user_id, app_key } = body;

    // First we want to fetch the article
    const article: IArticle | null = await Article.findOne({
      article_id,
    }).lean();

    // We create a variable, that will eventually be sent to the client
    let newArticle;

    // As an article document is only created once we have some data as likes or comments,
    // we need to check if we received anything form our article query or not, and handle both options slightly differently.
    if (article) {

      // If the likes array in the article document already contains the user's id,
      // we simply return without doing anything as the user can't like an article twice.
      if (article.likes.includes(user_id)) {
        return res.status(406).send("User already liked article");
      }

      // Otherwise, we update the article - we increase the like count by 1,
      // and add the user's id to the "likes" array
      await Article.findOneAndUpdate(
        { article_id },
        {
          $inc: { likes_count: 1 },
          $push: { likes: user_id },
        }
      );

      // We set the variable which we created before as the new article after the changes we've just made.
      newArticle = {
        ...article,
        likes_count: article.likes_count + 1,
        likes: [...article.likes, user_id],
      };
    }

    // Else, if we couldn't find an article document with the article ID we passed,
    // then it means that this like is the first piece of information we have a about this article
    else {

      // We simply create a new article document with one like,
      // and the user's id inside the likes array. We set the new article to the variable we created before
      newArticle = new Article({
        article_id,
        likes: [user_id],
        likes_count: 1,
        comments_count: 0,
        replies_count: 0,
      });
      await newArticle.save();
    }

    // If everything went well, we send the article back
    return res.status(200).send(newArticle);
  } catch (err: any) {
    return res.status(500).send({ error: err.message });
  }
});
Enter fullscreen mode Exit fullscreen mode

Then after this route, we add a route to handle un-likes (when a user revokes the like they gave before). Paste this route after our previous route.

router.post("/articles/unlike", async (req: ExReq, res: ExRes) => {
  try {
    const body = req.body;

    if (!body) throw new Error("No request body was found");

    const { article_id, user_id, app_key } = body;

    // We first need to find the article to make sure we've received a valid article id
    const article: IArticle | null = await Article.findOne({
      article_id,
    }).lean();

    // Not like the previous route, here we can't have a scenario in which an article document doesn't exist yet.
    // Un-liking an article implies the article has been liked before, which means it should already have a document in our database.
    // If we can't find the article, then we have some issue happening and we return a 404.
    // This shouldn't ever happen, but we do the check for extra safety.
    if (!article) {
      return res.status(404).send("Article not found");
    }

    // If the article's "likes" array doesn't include the user's id,
    // we simply return without doing anything as the user can't
    // unlike an article they don't currently like.
    if (!article.likes.includes(user_id)) {
      return res.status(406).send("Can't unlike, as user didn't like article");
    }

    // We update the article - we decrease the like count by 1,
    // and remove the user's id from the "likes" array.
    await Article.findOneAndUpdate(
      { article_id },
      {
        $inc: { likes_count: -1 },
        $pull: { likes: user_id },
      }
    );

    // We create a simple new article object with the updated data
    const newArticle = {
      ...article,
      likes_count: article.likes_count - 1,
      likes: article.likes.filter((l) => l !== user_id),
    };

    // If everything went well, we send the article back
    return res.status(200).send(newArticle);
  } catch (err: any) {
    return res.status(500).send({ error: err.message });
  }
});
Enter fullscreen mode Exit fullscreen mode

And with that - we are done with our articles routes!

Now, let's move on to our comment model, interface and routes.

In our "src/models" folder, create a "comment.ts" file and in it paste the following code:

import mongoose from "mongoose";

mongoose.Promise = global.Promise;
export const commentSchema = new mongoose.Schema({
  article_id: {
    type: String,
    trim: true,
    required: true,
  },
  body: {
    type: String,
    trim: true,
    required: true,
  },
  parent: {
    type: String,
    trim: true,
    required: false,
  },
  likes: {
    type: [String],
    required: true,
  },
  likes_count: {
    type: Number,
    required: true,
  },
  replies_count: {
    type: Number,
    required: true,
  },
  created_at: {
    type: String,
    trim: true,
    required: true,
  },
  author: {
    _id: {
      type: String,
      trim: true,
      required: true,
    },
    name: {
      type: String,
      trim: true,
    },
    img: {
      type: String,
      trim: true,
    },
  },
});

export const Comment =
  mongoose.models.Comment ?? mongoose.model("Comment", commentSchema);
Enter fullscreen mode Exit fullscreen mode

In your "src/interfaces" folder, create a file called "IComment" and in it paste the following:

export default interface IComment {
  _id: string;
  article_id: string;
  body: string;
  parent?: string;
  likes: string[];
  likes_count: number;
  replies_count: number;
  created_at: string;
  author: {
    _id: string;
    name: string;
    img?: string;
  };
Enter fullscreen mode Exit fullscreen mode

And lastly, in your "src/routes" folder, create a file called "comments.ts", and in it paste the following:

import { Router, Request as ExReq, Response as ExRes } from "express";
import { SortOrder } from "mongoose";
import { Comment } from "../models/comment";
import IComment from "../interfaces/IComment";
import { Article } from "../models/article";
import IArticle from "../interfaces/IArticle";

const router = Router();

### THIS IS WHERE OUR ROUTES WOULD COME ###

export default router;
Enter fullscreen mode Exit fullscreen mode

Now, let's start adding our comment routes. Like before, I will add the routes one by one, with all the notes written within the route itself. Also like before, in some of the routes you will see an unused "app_key" variable.

First, let's add the route to handle fetching comments. As replies are comments too, this route handles both fetching of comments and replies (when replies are requested). Just above your export statement, post the following route:

router.get("/comments", async (req: ExReq, res: ExRes) => {
  try {
    const {
      app_key,
      article_id,
      sort_by,
      parent,
      page = 1,
      limit = 5,
    } = req.query;

    // we want to paginate our results, so we need to have two pieces of data
    // 1. How many results we want to get (what is the limit),
    // 2. What page are we currently on.
    // As we get both of these pieces of information as strings,
    // we first convert them to a number
    // (and throw an error if any of them can't be converted to a number)
    const limitAsNumber = Number(limit);
    if (isNaN(limitAsNumber)) {
      throw new Error("Invalid request: limit must be a number");
    }

    const pageAsNumber = Number(page);
    if (isNaN(pageAsNumber)) {
      throw new Error("Invalid request: page must be a number");
    }

    // Also page can't be lower than 1, so we check for that as well
    if (pageAsNumber < 1) {
      throw new Error("Invalid request: page must be greater than 1");
    }

    // Also page can't have a decimal so we check for that as well
    if (pageAsNumber % 1 != 0) {
      throw new Error("Invalid request: page must be a whole number");
    }

    // Next we define our sort filter based on what we got in our query
    let sort: { [key: string]: SortOrder } = {};

    if (sort_by === "popular") {
      sort = { likes_count: -1, created_at: -1 };
    }
    if (sort_by === "newest") {
      sort = { created_at: -1 };
    }
    if (sort_by === "oldest") {
      sort = { created_at: 1 };
    }

    // Based on the page number and the limit, we figure out how many results we need to skip over
    const skipCount = (pageAsNumber - 1) * limitAsNumber;

    // We look up the comments we need with our filters
    const result = await Comment.find({
      article_id,
      parent,
    })
      .limit(limitAsNumber)
      .skip(skipCount)
      .sort(sort);

    // If all went well, we return the array with the comments
    return res.status(200).send(result);
  } catch (err: any) {
    return res.status(500).send({ error: err.message });
  }
});
Enter fullscreen mode Exit fullscreen mode

Under this route, add the route for posting a new comment:

router.post("/comments", async (req: ExReq, res: ExRes) => {
  try {
    const body = req.body;

    if (!body) throw new Error("No comment details were found");

    const { app_key, article_id, comment_body, parent, author } = body;

    const comment = new Comment({
      article_id,
      body: comment_body,
      parent,
      likes: [],
      likes_count: 0,
      replies_count: 0,
      created_at: new Date(),
      author,
    });
    await comment.save();

    // As comments and replies are treated as the same,
    // we need to check if this is a reply by checking if the comment has a parent or not.
    // If it does, we want to update the parent about the increase in its replies count
    if (parent) {
      await Comment.findByIdAndUpdate(parent, {
        $inc: { replies_count: 1 },
      });
    }

    // We try to fetch the article this comment belongs to
    const article: IArticle | null = await Article.findOne({
      article_id,
    });

    // Similarly to a situation we had in the articles routes,
    // we handle this situation also slightly differently whether we already have an article document or not.

    if (article) {
      // If we already have an article, we need to check if this comment has a parent or not.
      // Our article object keeps track of direct comments count and indirect comments count (replies to comments).
      // We need to check if this comment is a direct comment in order for us to know what property we need to update.

      // If it has a parent, it means this is an indirect comment to the article,
      // so we update the "replies_count" property in the article object
      if (parent) {
        await Article.findOneAndUpdate(
          { article_id },
          {
            $inc: { replies_count: 1 },
          }
        );
      }

      // If there is no parent, then this is a direct comment to the article,
      // so we update the "comments_count" property in the article object
      else {
        await Article.findOneAndUpdate(
          { article_id },
          {
            $inc: { comments_count: 1 },
          }
        );
      }
    }

    // If there is no article then surely this is a first level comment and there's no need to check if it has a parent
    else {

      // We simply create a new article with a comment count of one
      const newArticle = new Article({
        article_id,
        likes: [],
        likes_count: 0,
        comments_count: 1,
        replies_count: 0,
      });
      await newArticle.save();
    }

    // If all went well, we send the comment back
    return res.status(200).send(comment);
  } catch (err: any) {
    return res.status(500).send({ error: err.message });
  }
});
Enter fullscreen mode Exit fullscreen mode

After this route, we will add our route for deleting a comment. This route is a slightly more tricky, as we want to make sure we don't only delete the comment, but also all of the children of that comment (direct and indirect). We also want to keep track of how many documents we've deleted in total, to update the article object. Paste the following route, and read the notes to understand what we are doing:

router.delete("/comments", async (req: ExReq, res: ExRes) => {
  try {
    const body = req.body;
    const { app_key, comment_id } = body;

    // We first find the comment. If all good, we should get the comment document back.
    // We will do all of our delete operations with a recursive function,
    // but we still need to get a hold of the initial target comment for other data manipulations
    const targetComment: IComment | null = await Comment.findById(comment_id);

    // If we got no comment document back then we throw an error
    if (!targetComment) throw new Error("Comment not found");

    // We will keep a count of the number of comments that have been deleted
    // so we could update the count in our article document
    let firstLevelCommentsDeletedCount = 0;
    let repliesDeletedCount = 0;

    // Let's create a recursive function that deletes comments and their children
    async function deleteCommentAndChildren(commentId: string) {
      // We first delete the comment. If all good, we should get the comment document back.
      const comment: IComment | null = await Comment.findByIdAndDelete(
        commentId
      );

      // If we got no comment document back then we throw an error.
      // Because this recursive function is based on the result of a previous operation,
      // the only time it could potentially fail in theory should be for the first comment ID we pass,
      // as all other IDs we will get back from our queries.
      if (!comment) throw new Error("Comment not found");

      // If this comment has a parent, we increase the count for the replies deleted,
      // and if it doesn't, then we increase the count for first level comments deleted.
      // The count for first level comments deleted could be at most one.
      if (comment.parent) {
        repliesDeletedCount += 1;
      } else {
        firstLevelCommentsDeletedCount += 1;
      }

      // We find all the replies for the comment we've just deleted,
      // and run them through this same function again as they need to get deleted as well.
      const childComments: IComment[] = await Comment.find({
        parent: comment._id,
      });

      // We now run each of the child comments through the same function
      for (const childComment of childComments) {
        await deleteCommentAndChildren(childComment._id);
      }
    }

    // We start our recursive function to delete this comment and all of its child comments.
    await deleteCommentAndChildren(comment_id);

    // If the target comment had a parent (meaning it was a reply)
    // then we should update the parent's reply count
    if (targetComment.parent) {
      await Comment.findByIdAndUpdate(targetComment.parent, {
        $inc: { replies_count: -1 },
      });
    }

    // We need to update the article's total number of comments and replies
    const article: IArticle | null = await Article.findOneAndUpdate(
      { article_id: targetComment.article_id },
      {
        $inc: {
          comments_count: -firstLevelCommentsDeletedCount,
          replies_count: -repliesDeletedCount,
        },
      }
    ).lean();

    // If for some reason we couldn't find the article
    // that needed to get updated we throw an error
    if (!article) throw new Error("No article found");

    // We create an updated article object to pass back to the comment section
    const updatedArticle = {
      ...article,
      comments_count: article.comments_count - firstLevelCommentsDeletedCount,
      replies_count: article.replies_count - repliesDeletedCount,
    };

    return res.status(200).send(updatedArticle);
  } catch (err: any) {
    return res.status(500).send({ error: err.message });
  }
});
Enter fullscreen mode Exit fullscreen mode

After the previous route, paste the following route which handles an update of a comment:

router.patch("/comments", async (req: ExReq, res: ExRes) => {
  try {
    const body = req.body;
    const { update, comment_id } = body;

    await Comment.findOneAndUpdate({ _id: comment_id }, { body: update });

    return res.status(200).send();
  } catch (err: any) {
    return res.status(500).send({ error: err.message });
  }
});
Enter fullscreen mode Exit fullscreen mode

After the previous route, all we have to add are two more routes: liking a comment and un-liking a comment.

Let's start by adding the liking a comment route:

router.post("/comments/like", async (req: ExReq, res: ExRes) => {

  try {
    const body = req.body;

    if (!body) throw new Error("No request body was found");

    const { comment_id, user_id } = body;

    // We first need to find the comment
    const comment: IComment | null = await Comment.findById(comment_id).lean();

    // If we couldn't find the comment we return a 404
    if (!comment) {
      return res.status(404).send("Comment not found");
    }

    // If the likes array in the comment document already contains the user's id,
    // we simply return without doing anything as the user can't like a comment twice.
    if (comment.likes.includes(user_id)) {
      return res.status(406).send("Can't like - user already liked comment");
    }

    // Otherwise, we update the comment -
    // we increase the like count by 1, and add the user's id to the "likes" array
    await Comment.findByIdAndUpdate(comment_id, {
      $inc: { likes_count: 1 },
      $push: { likes: user_id },
    });

    // We create a simple comment object with the updated comment data
    const newComment = {
      ...comment,
      likes_count: comment.likes_count + 1,
      likes: [...comment.likes, user_id],
    };

    // If everything went well, we send the comment back
    return res.status(200).send(newComment);
  } catch (err: any) {
    return res.status(500).send({ error: err.message });
  }
});
Enter fullscreen mode Exit fullscreen mode

And lastly, let's add the route for un-liking a comment:

router.post("/comments/unlike", async (req: ExReq, res: ExRes) => {
  try {
    const body = req.body;

    if (!body) throw new Error("No request body was found");

    const { comment_id, user_id } = body;

    // We first need to find the comment
    const comment: IComment | null = await Comment.findById(comment_id).lean();

    // If we couldn't find the comment we return a 404
    if (!comment) {
      return res.status(404).send("Comment not found");
    }

    // If the comment's "likes" array doesn't include the user's id,
    // we simply return without doing anything as the user can't unlike an comment they don't currently like.
    if (!comment.likes.includes(user_id)) {
      return res.status(406).send("Can't unlike, as user didn't like comment");
    }

    // We update the comment - we decrease the like count by 1,
    // and remove the user's id from the "likes" array
    await Comment.findByIdAndUpdate(comment_id, {
      $inc: { likes_count: -1 },
      $pull: { likes: user_id },
    });

    // We create a simple new comment object with the updated data
    const newComment = {
      ...comment,
      likes_count: comment.likes_count - 1,
      likes: comment.likes.filter((l) => l !== user_id),
    };

    // If everything went well, we send the comment back
    return res.status(200).send(newComment);
  } catch (err: any) {
    return res.status(500).send({ error: err.message });
  }
});
Enter fullscreen mode Exit fullscreen mode

And with that, we finished the routes for our comments, and the entire API work we needed to make Replyke work with our own server!

Remember to add both new route handlers to your express app in your index file:

import express from "express";
import http from "http";
import articlesRouter from "./routers/articles";
import commentsRouter from "./routers/comments";

const app = express();
const server = http.createServer(app);

.
.
.
.

app.use(articlesRouter);
app.use(commentsRouter);

const PORT = process.env.PORT;

server.listen(PORT, () => {
  console.log(`SERVER IS RUNNING ON PORT ${PORT}`);
});
Enter fullscreen mode Exit fullscreen mode

Switch Replyke to use your server

Now that we've created our own API to work with Replyke, all we have left to do is let Replyke know, so it would use our server instead of Replyke's.

Now, when working with our own API with Replyke, we have two options.
We can either pass to Replyke a simple base url, and Replyke would complete the appropriate routes for each of the API calls it would make, or we can pass to it exact paths to use for every action that makes a call to our API.

You're probably wondering, why would I do that and which option should I use?

For most users, passing a simple base URL is definitely the way to go. It is the easiest and works perfectly fine. The only two reasons for you to pass exact paths are:

  1. You already have a collection named comments or a collection named articles, so letting Replyke complete the paths for you would mess with your current DB structure.
  2. You want to implement the routes in some other way, and not how I've showed in this article.

# Note: changes within the routes are not an issue, as long as the route serves the same purpose as I've showed you. But if you want to change the paths themselves, then you should pass exact paths to Replyke.

For example, if you want the path for editing a comment to be "BASE_URL/comments/edit" instead of simply sending a patch API call to "BASE_URL/comments", then you should pass exact paths.

Let's first go over the simple option. If we want Relpyke to complete the paths for us, all we have to do is pass our base url using the "apiBaseUrl" prop. Our CommentSection component would then be implemented as so:

<CommentSection
 appKey="YOUR_APP_KEY"
 articleId="UNIQUE_ARTICLE_ID"
 styleId="STYLE_ID"
 apiBaseUrl="YOUR_BASE_URL" // <-- Our newly added prop
 callbacks={{
    loginClickCallback: LOGIN_CALLBACK,
    commentAuthorClickCallback: COMMENT_AUTHOR_CLICK_CALLBACK,
    currentUserClickCallback: CURRENT_USER_CLICK_CALLBACK
 }}
 currentUser={user ? {
    _id: USER_ID,
    name: USER_NAME,
    img: USER_IMAGE
 } : undefined}
 /> 
Enter fullscreen mode Exit fullscreen mode

Now, if the simple implementation doesn't work for you, and you prefer to pass each of the paths individually, we would do so by passing our paths with the "apiPaths" prop. Our CommentSection component would then be implemented as so:

<CommentSection
 appKey=YOUR_APP_KEY
 articleId=UNIQUE_ARTICLE_ID
 styleId=STYLE_ID
 apiPaths={{ // <-- Our newly added prop
    fetchArticlePath: FETCH_ARTICLE_PATH,
    likeArticlePath: LIKE_ARTICLE_PATH,
    unlikeArticlePath: UNLIKE_ARTICLE_PATH,
    fetchCommentsPath: FETCH_COMMNENTS_PATH,
    postCommentPath: POST_COMMENT_PATH,
    removeCommentPath: REMOVE_COMMENT_PATH,
    saveEditedCommentPath: SAVE_EDITED_COMMENT_PATH,
    likeCommentPath: LIKE_COMMENT_PATH,
    unlikeCommentPath: UNLIKE_COMMENT_PATH,
 }}
 callbacks={{
    loginClickCallback: LOGIN_CALLBACK,
    commentAuthorClickCallback: COMMENT_AUTHOR_CLICK_CALLBACK,
    currentUserClickCallback: CURRENT_USER_CLICK_CALLBACK
 }}
 currentUser={user ? {
    _id: USER_ID,
    name: USER_NAME,
    img: USER_IMAGE
 } : undefined}
 /> 
Enter fullscreen mode Exit fullscreen mode

# Note: the "apiPaths" prop is of course optional, but if we use it, we must add a value for all of the paths required.

# Note 2: when we use he "apiBaseUrl" (basic implementation) together with "apiPaths", then "apiBaseUrl" will be completely ignored, and Replyke will use the apiPaths only.

And.. that's it! With that, we now have a fully functioning comment section in our project, that works with our own database.

If you have any thoughts regarding this tutorial and/or the library - please share! I would like to make both easy to understand and use.

Thank you for reading!

Top comments (0)