DEV Community

Cover image for Building a Dynamic Blog Dashboard with Next.js
Vitor Alecrim
Vitor Alecrim

Posted on

Building a Dynamic Blog Dashboard with Next.js

Introduction

Hello, how are you? This is Vítor, coming back with a new project to help you enhance your programming skills. It’s been a while since I last published a tutorial. Over the past few months, I took some time to rest and focus on other activities. During this period, I developed a small web project: a blog, which became the focus of this tutorial.

In this guide, we will create the frontend of a blog page capable of rendering Markdown. The application will include public and private routes, user authentication, and the ability to write Markdown text, add photos, display articles, and much more.

Feel free to customize your application however you prefer—I even encourage it.

You can access the repository for this application here:

GitHub logo Gondrak08 / blog-platform

A blog plataform made with Next.js/typescript.

Plataforma para blog

Ingredientes

Como usar

npm i
npm run start

Server

você pode encontrar o servidor dessa aplicação em server




This tutorial also includes the writing of the Node.js server that will be used in this guide:

I hope you enjoy it.

Happy coding!

Libraries

Here is a summary of the libraries used in this project:

Creating the React Project

We will use the latest version of the Next.js framework, which, at the time of writing this tutorial, is version 13.4.

Run the following command to create the project:

npx create-next-app myblog
Enter fullscreen mode Exit fullscreen mode

During the installation, select the template settings. In this tutorial, I will use TypeScript as the programming language and the Tailwind CSS framework for styling our application.

Configuration

Now let's install all the libraries we will use.

Markdown
npm i  markdown-it @types/markdown-it markdown-it-style github-markdown-css react-markdown
Enter fullscreen mode Exit fullscreen mode
React Remark
remark remark-gfm remark-react
Enter fullscreen mode Exit fullscreen mode
Codemirror
npm @codemirror/commands @codemirror/highlight @codemirror/lang-javascript @codemirror/lang-markdown @codemirror/language @codemirror/language-data @codemirror/state @codemirror/theme-one-dark @codemirror/view
Enter fullscreen mode Exit fullscreen mode
Icons
npm i react-icons @types/react-icons
Enter fullscreen mode Exit fullscreen mode

Then clean up the initial structure of your installation by removing everything we won’t be using.

Architecture

This is the final structure of our application.

src-
  |- app/
  |    |-(pages)/
  |    |      |- (private)/
  |    |      |       |- (home)
  |    |      |       |- editArticle/[id]
  |    |      |       |
  |    |      |       |- newArticle
  |    |      | - (public)/
  |    |              | - article/[id]
  |    |              | - login
  |    |
  |   api/
  |    |- auth/[...nextAuth]/route.ts
  |    |- global.css
  |    |- layout.tsx
  |
  | - components/
  | - context/
  | - interfaces/
  | - lib/
  | - services/
middleware.ts
Enter fullscreen mode Exit fullscreen mode

First Steps

Configuring next.config

In the root of the project, in the file next.config.js, let's configure the domain address from which we will access the images for our articles. For this tutorial, or if you're using a local server, we will use localhost.

Make sure to include this configuration to ensure the correct loading of images in your application.

const nextConfig = {
   images: {
    domains: ["localhost"],
  },
};
Enter fullscreen mode Exit fullscreen mode

Configuring Middleware

In the root folder of the application src/, create a middleware.ts to verify access to private routes.

export { default } from "next-auth/middleware";
export const config = {
  matcher: ["/", "/newArticle/", "/article/", "/article/:path*"],
};
Enter fullscreen mode Exit fullscreen mode

To learn more about middlewares and everything you can do with them, check the documentation.

Configuring Authentication Route

Inside the /app folder, create a file named route.ts in api/auth/[...nextauth]. It will contain the configuration for our routes, connecting to our authentication API using the CredentialsProvider.

The CredentialsProvider allows you to handle login with arbitrary credentials, such as username and password, domain, two-factor authentication, hardware device, etc.

First, in the root of your project, create a .env.local file and add a token that will be used as our secret.

.env.local
NEXTAUTH_SECRET = SubsTituaPorToken
Enter fullscreen mode Exit fullscreen mode

Next, let's write our authentication system, where this NEXTAUTH_SECRET will be added to our secret in the src/app/auth/[...nextauth]/routes.ts file.

import NextAuth from "next-auth/next";
import type { AuthOptions } from "next-auth";
import CredentialsProvider from "next-auth/providers/credentials";
import { authenticate } from "@/services/authService";
import refreshAccessToken from "@/services/refreshAccessToken";

export const authOptions: AuthOptions = {
  providers: [
    CredentialsProvider({
      name: "credentials",
      credentials: {
        email: {
          name: "email",
          label: "email",
          type: "email",
          placeholder: "Email",
        },
        password: {
          name: "password",
          label: "password",
          type: "password",
          placeholder: "Password",
        },
      },
      async authorize(credentials, req) {
        if (typeof credentials !== "undefined") {
          const res = await authenticate({
            email: credentials.email,
            password: credentials.password,
          });
          if (typeof res !== "undefined") {
            return { ...res };
          } else {
            return null;
          }
        } else {
          return null;
        }
      },
    }),
  ],

  session: { strategy: "jwt" },
  secret: process.env.NEXTAUTH_SECRET,
  callbacks: {
    async jwt({ token, user, account }: any) {
      if (user && account) {
        return {
          token: user?.token,
          accessTokenExpires: Date.now() + parseInt(user?.expiresIn, 10),
          refreshToken: user?.tokenRefresh,
        };
      }

      if (Date.now() < token.accessTokenExpires) {
        return token;
      } else {
        const refreshedToken = await refreshAccessToken(token.refreshToken);
        return {
          ...token,
          token: refreshedToken.token,
          refreshToken: refreshedToken.tokenRefresh,
          accessTokenExpires:
            Date.now() + parseInt(refreshedToken.expiresIn, 10),
        };
      }
    },
    async session({ session, token }) {
      session.user = token;
      return session;
    },
  },

  pages: {
    signIn: "/login",
    signOut: "/login",
  },
};

const handler = NextAuth(authOptions);
export { handler as GET, handler as POST };
Enter fullscreen mode Exit fullscreen mode

Authentication Provider

Let's create an authentication provider, a context, that will share our user's data across the pages of our private route. We will later use it to wrap one of our layout.tsx files.

Create a file in src/context/auth-provider.tsx with the following content:

'use client';
import React from 'react';
import { SessionProvider } from "next-auth/react";
export default function Provider({
    children,
    session
}: {
    children: React.ReactNode,
    session: any
}): React.ReactNode {
    return (
        <SessionProvider session={session} >
            {children}
        </SessionProvider>
    )
};
Enter fullscreen mode Exit fullscreen mode

Global Styles

Overall, in our application, we will use Tailwind CSS to create our styling. However, in some places, we will share custom CSS classes between pages and components.

/*global.css*/
.container {
  max-width: 1100px;
  width: 100%;
  margin: 0px auto;
}

.image-container {
  position: relative;
  width: 100%;
  height: 5em;
  padding-top: 56.25%; /* Aspect ratio 16:9 (dividindo a altura pela largura) */
}

.image-container img {
  position: absolute;
  top: 0;
  left: 0;
  width: 100%;
  height: 100%;
  object-fit: cover;
}

@keyframes spinner {
  0% {
    transform: rotate(0deg);
  }
  100% {
    transform: rotate(360deg);
  }
}

.loading-spinner {
  width: 50px;
  height: 50px;
  border: 10px solid #f3f3f3;
  border-top: 10px solid #293d71;
  border-radius: 50%;
  animation: spinner 1.5s linear infinite;
}
Enter fullscreen mode Exit fullscreen mode

Layouts

Now let's write the layouts, both private and public.

app/layout.tsx

import type { Metadata } from "next";
import { Inter } from "next/font/google";
import "./globals.css";
import Provider from "@/context/auth-provider";
import { getServerSession } from "next-auth";
import { authOptions } from "./api/auth/[...nextauth]/route";
const inter = Inter({ subsets: ["latin"] });

export const metadata: Metadata = {
  title: "Markdown Text Editor",
  description: "Created by <@vitorAlecrim>",
};

export default async function RootLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  const session = await getServerSession(authOptions);
  return (
    <Provider session={session}>
      <html lang="en">
        <body className={inter.className}>{children}</body>
      </html>
    </Provider>
  );
}
Enter fullscreen mode Exit fullscreen mode

pages/layout.tsx

import Navbar from "@/components/Navbar";
export default function PrivatePagesLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  return (
    <>
      <header className="w-full  ">
        <Navbar />
      </header>
      <div className="container">{children}</div>
    </>
  );
}
Enter fullscreen mode Exit fullscreen mode

API Calls

Our application will make several calls to our API, and you can adapt this application to use any external API. In our example, we are using our local application. If you haven’t seen the backend tutorial and the server creation, check it out.

In src/services/, let's write the following functions:

  1. authService.ts: function responsible for authenticating our user on the server.
export const authenticate = async ({
  email,
  password,
}: {
  email: string;
  password: string;
}) => {
  const response = await fetch(`http://localhost:8080/user/login`, {
    method: "POST",
    headers: {
      "content-type": "application/json",
    },
    body: JSON.stringify({
      email: email,
      password: password,
    }),
  });
  const user = await response.json();

  if (!response.ok) {
    throw new Error(user.message);
  }
  if (user) {
    return user;
  }

  return null;
};
Enter fullscreen mode Exit fullscreen mode

2.refreshAccessToken.tsx:

export default async function refreshAccessToken(refreshToken: string) {
  const headers = {
    "Content-Type": "application/json",
  };

  const data = {
    refreshToken: refreshToken,
  };

  try {
    const res = await fetch("http://localhost:8080/user/refresh-token", {
      method: "POST",
      headers: headers,
      body: JSON.stringify(data),
    });

    console.log("new call token -->", res);
    const token = await res.json();
    return token;
  } catch (error) {
    console.error("error refreshing token", error);
    throw error;
  }
}
Enter fullscreen mode Exit fullscreen mode
  1. getArticles.tsx: function responsible for fetching all the articles saved in our database:
export default async function getArticals() {
  try {
    const res = await fetch("http://localhost:8080/articles/getAll", {
      cache: "no-cache",
    });
    const data = await res.json();
    return data;
  } catch (error) {
    console.log("something wrong just happend", error);
  }
}
Enter fullscreen mode Exit fullscreen mode
  1. postArticle.tsx: function responsible for submitting the article data to our server.
import { IProp } from "@/interfaces/services.interface";

export default async function postArtical(prop:IProp){
    const {token,title, doc,imageUrl} = prop;
    const formData = new FormData();
    formData.append('title',title);
    formData.append('thumb', imageUrl);
    formData.append('content', doc);

    const headers = {
        'x-access-token': token
    };

    try{
        const res = await fetch('http://localhost:8080/articles/add',{
            method:'POST',
            headers:headers,
            body:formData
        })
        const result = await res.json();
        return result;
    } catch(error){
        console.log('Error:', error);
        console.log('something wrong just happend', await error);
    }
}
Enter fullscreen mode Exit fullscreen mode
  1. editArticle.tsx: function responsible for modifying a specific article within the database.
import { IProp } from "@/interfaces/services.interface";
export default async function editArtical(prop: IProp) {
  const { id, token, imageUrl, title, doc } = prop;
  const formData = new FormData();
  formData.append("title", title);
  formData.append("thumb", imageUrl);
  formData.append("content", doc);

  const headers = {
    "x-access-token": token,
  };

  try {
    const res = await fetch(`http://localhost:8080/articles/edit/${id}`, {
      method: "PATCH",
      headers: headers,
      body: formData,
    });
    const result = await res.json();
    return result;
  } catch (error) {
    console.log("Error:", error);
    console.log("something wrong just happend", await error);
    throw error;
  }
}
Enter fullscreen mode Exit fullscreen mode
  1. deleteArticle.tsx: function responsible for removing a specific article from our database.
interface IProp {
  id: number;
  token: string;
}

export default async function deleteArtical(prop: IProp) {
  const { id, token } = prop;

  const headers = {
    "x-access-token": token,
  };

  try {
    const res = await fetch(`http://localhost:8080/articles/delete/${id}`, {
      method: "DELETE",
      headers: headers,
    });
    const result = await res.json();
    return result;
  } catch (error) {
    console.log("Error:", error);
    console.log("something wrong just happend", await error);
  }
}
Enter fullscreen mode Exit fullscreen mode

Components

Next, let's write each component used throughout the application.

Components/Navbar.tsx

A simple component with two navigation links.

import { TfiWrite } from "react-icons/tfi";
import { BsNewspaper } from "react-icons/bs";
import Link from "next/link";
import SignOutButton from "./SignOutButton";
export default function Navbar() {
  const linkStyle = "flex items-center  gap-2 hover:text-slate-600";
  return (
    <section className="container h-fit px-24 py-5">
      <div className="flex items-center justify-between  px-6">
        <div className="flex iems-center gap-8">
          <Link href="/" className={linkStyle}>
            <BsNewspaper className="w-[3em] h-[3em] font-light" />
          </Link>
          <Link href="/newArticle" className={linkStyle}>
            <TfiWrite className="text-sm w-[2.5em] h-[2.5em]" />
            <span className="text-[14px]">Escreva</span>
          </Link>
        </div>
        <SignOutButton />
      </div>
    </section>
  );
}
Enter fullscreen mode Exit fullscreen mode

Components/Loading.tsx

A simple loading component, used while waiting for API calls to complete.

export default function Loading() {
  return (
    <div className="loading-container w-full h-fit flex items-center justify-center">
      <div className="spinner-container">
        <div className="loading-spinner"></div>
      </div>
    </div>
  );
}

Enter fullscreen mode Exit fullscreen mode

Components/Pagination.tsx

A pagination component used on our page displaying all of our articles, in our private route. You can find a more detailed article on how to write this component here

import { FaArrowLeft, FaArrowRight } from 'react-icons/fa';

interface IPagination {
  currentPage: number;
  totalPages: number;
  onPageChange: (pageNumber: number) => void;
}

export default function Pagination(props: IPagination) {
  const { currentPage, totalPages, onPageChange } = props;

  const handlePrevClick = () => {
    if (currentPage > 0) {
      onPageChange(currentPage - 1);
    }
  };

  const handleNextClick = () => {
    if (currentPage < totalPages - 1) {
      onPageChange(currentPage + 1);
    }
  };

  const getPageNumbers = () => {
    const visiblePageCount = 4;
    const pageNumbers: number[] = [];

    if (totalPages <= visiblePageCount) {
      pageNumbers.push(...Array.from({ length: totalPages }, (_, i) => i + 1));
    } else {
      const firstPage = 0;
      const lastPage = totalPages - 1;

      const midPageCount = visiblePageCount - 2;

      const step = Math.floor(midPageCount / 2);
      pageNumbers.push(firstPage);

      if (currentPage < firstPage + step) {
        pageNumbers.push(...Array.from({ length: Math.min(midPageCount, totalPages) }, (_, i) => firstPage + i + 1));
      } else if (currentPage > lastPage - step) {
        pageNumbers.push(...Array.from({ length: Math.min(midPageCount, totalPages) }, (_, i) => lastPage - midPageCount + i));
      } else {
        const start = currentPage - step;
        pageNumbers.push(...Array.from({ length: midPageCount }, (_, i) => start + i + 1));
      }

      pageNumbers.push(lastPage + 1);
    }

    return pageNumbers;
  };

  const pageNumbers = getPageNumbers();

  return (
    <nav className="flex mx-auto w-fit">
      <ul id="pagination" className="flex items-center font-epilogue font-[500] text-[14px] border border-mv-gray-300 rounded-xl">
        <li className="border-r-[1px] border-r-mv-gray-300">
          <button
            className={`page-item text-sm md:text-md p-2 rounded-md flex items-center gap-3
              ${currentPage === 0 ? 'disable cursor-not-allowed text-mv-blue-200' : 'text-mv-blue-600'}`}
            onClick={handlePrevClick}
            disabled={currentPage === 0}
          >
            <FaArrowLeft className="text-mv-blue-200" />
            Anterior
          </button>
        </li>

        <div className="flex h-full w-full items-center justify-center">
          {pageNumbers.map((pageNumber) => (
            <li
              key={pageNumber - 1} 
              className={`page-item flex items-center w-full h-full border-r-[1px] border-r-mv-gray-300 last:border-r-0
                            ${pageNumber === currentPage + 1 ? 'active text-mv-blue-600' : 'text-mv-blue-200'}`}
            >
              <button className="page-link w-full h-full px-4" onClick={() => onPageChange(pageNumber - 1)}>
                {pageNumber}
              </button>
            </li>
          ))}
        </div>

        <li className="border-l-[1px] border-r-mv-gray-300">
          <button
            className={`page-item text-sm md:text-md p-2 rounded-md flex items-center gap-3 font- ${currentPage === totalPages - 1 ? 'disable cursor-not-allowed text-mv-blue-200' : '  text-mv-blue-600'
              }`}
            onClick={handleNextClick}
            disabled={currentPage === totalPages - 1}
          >
            Próxima
            <FaArrowRight className="text-mv-blue-200" />
          </button>
        </li>
      </ul>
    </nav>
  );
}
Enter fullscreen mode Exit fullscreen mode

Components/SignOutButton

A button component for logging the user out of the application.

"use client";
import React from "react";
import { signOut } from "next-auth/react";
import { BiExit } from "react-icons/bi";

const SignOutButton = () => {
  return (
    <div className="h-fit w-fit flex items-center gap-1">
      sair
      <BiExit
        className="text-slate-500 hover:text-slate-600 w-7 h-7 cursor-pointer"
        onClick={() => {
          signOut({ redirect: true, callbackUrl: "/login" });
        }}
      />
    </div>
  );
};

export default SignOutButton;
Enter fullscreen mode Exit fullscreen mode

Components/ArticleCard.tsx

A card component for displaying written articles.

This component also contains a link that will lead to both the article display page and the page for editing a previously written article.

import Image from "next/image";
import MarkdownIt from "markdown-it";
import Link from "next/link";
import { AiOutlineEdit } from "react-icons/ai";

export interface IArticleCard {
  id: number;
  title: string;
  description: string | null;
  image: string;
  link: string;
}

export default function ArticleCard({
  id,
  title,
  description,
  image,
}: IArticleCard) {
  const md = new MarkdownIt({
    html: true,
    linkify: true,
    typographer: true,
  });
  const mdTitle = md.render(title);
  const mdDescription = description && md.render(description);
  return (
    <div id="article-card" className="w-full h-full relative ">
      <Link
        href={`editArticle/${id}`}
        className="w-fit h-fit absolute z-30 top-3 right-3"
      >
        <AiOutlineEdit className="w-8 h-8  text-white hover:text-yellow-200" />
      </Link>

      <Link
        href={`article/${id}`}
        rel="noopener noreferrer"
        className="flex flex-col gap-2 w-full h-full shadow-xl hover:shadow-2xl z-10"
      >
        <div className="image-container relative">
          <Image
            src={image}
            alt={title}
            className="absolute object-contain w-full h-full"
            fill
          />
        </div>
        <div className="p-2 h-full relative">
          <div
            className="text-sm md:text-[15px]"
            dangerouslySetInnerHTML={{ __html: mdTitle }}
          />
          {mdDescription && (
            <div dangerouslySetInnerHTML={{ __html: mdDescription }} />
          )}
        </div>
      </Link>
        </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

Components/PreviewText.tsx

A component responsible for displaying the text we are writing in our editor. It uses a different library from article. If you prefer, you can adapt the component to use the same library.

import React from "react";
import { unified } from "unified";
import remarkParse from "remark-parse";
import remarkGfm from "remark-gfm";
import remarkReact from "remark-react";
import Image from "next/image";
interface Props {
  doc: string;
  title: string;
  previewImage: string;
  onPreview: () => void;
}
const Preview: React.FC<Props> = (props) => {
  const md: any = unified()
    .use(remarkParse)
    .use(remarkGfm)
    .use(remarkReact as any, React)
    .processSync(props.doc).result;

  return (
    <section className='h-[50em] w-full flex flex-col gap-5'>
      <div className='w-full'>
        <button onClick={() => props.onPreview()}>X</button>
      </div>
      {props.previewImage.length > 0 && (
        <div className='w-full h-[10em] relative'>
          <Image
            alt='prev-image'
            layout='fill'
            className='w-[10em] h-[10em] object-cover bg-center bg-no-repeat absolute'
            src={props.previewImage}
          />
        </div>
      )}
      <div className='w-full h-full'>
        <div className=''>
          <h1 className='text-black text-2xl'>{props.title}</h1>
        </div>
        <div className='preview markdown-body text-black h-full'>
          Preview {md}{" "}
        </div>
      </div>
    </section>
  );
};
export default Preview;
Enter fullscreen mode Exit fullscreen mode

Components/ArticleList.tsx

A component responsible for making API calls and displaying the response.

Here, we will use two API calls through the functions we wrote:

  1. getArticles.ts - returns all the articles that will be displayed in the component.
  2. removeArticle - removes a specific article from our list and from our server.

We will use the Pagination.tsx component, previously written, to split the number of articles across pages.

"use client";
import { useState, useEffect } from "react";
import { useSession } from "next-auth/react";
import { redirect } from "next/navigation";
import getArticals from "@/services/getArticles";
import { IArticle } from "@/interfaces/article.interface";
import ArticleCard from "./articleCard";
import Pagination from "./Pagination";
import Loading from "./Loading";
import {MdOutlineAutoDelete} from 'react-icons/md'
import deleteArtical from "@/services/deleteArticle";

const ArticleList = () => {
  const linkStyle = "flex items-center  gap-2 hover:text-slate-600";
  const { data: session }: any = useSession({
    required: true,
    onUnauthenticated() {
      redirect("/login");
    },
  });

  const [articles, setArticles] = useState<IArticle[]>([]);
  const [currentPage, setCurrentPage] = useState<number>(0);
  const handlePageChange = (pageNumber: number) => {
    setCurrentPage(pageNumber);
  };
  const itemsPerPage: number = 10;
  const paginatedItems: any[] = [];

  for (let i = 0; i < articles.length; i += itemsPerPage) {
    paginatedItems.push(articles.slice(i, i + itemsPerPage));
  }

  const articlesToDisplay = paginatedItems[currentPage]?.slice(0, 8);

 async function removeArticle(id: number) {
    const token: string = session?.user?.token;
    const deleteArr = await deleteArtical({ id, token });
    console.log(deleteArr?.ok);
    if(deleteArr?.ok){
      const filter = articles.filter((article: IArticle, index: number) =>
      article.id !== id ? id : null,
      );
     setArticles(filter);
    }
  }

  useEffect(() => {
    const getData = async () => {
      const data = await getArticals();
      setArticles(data.reverse());
    };
    getData();
  }, [articles]);

  if (articles.length === 0) return <Loading />;

  return (
    <section
      className="
      w-full h-full px-5"
    >
      <h1 className="text-2xl text-slate-800 text-bold my-3">Seus textos</h1>
      <div className="w-full border border-slate-300 my-3"/>
      <div className="h-full grid md:grid-cols-2 lg:grid-cols-3  xl:grid-cols-4 md:gap-2 xl:gap-3 ">
        {Array.isArray(articlesToDisplay) &&
          articlesToDisplay.map((article: IArticle, index: number) => {
            return (
              <div key={index} className="relative w-full h-full">
                <ArticleCard
                  id={article.id}
                  image={`http://localhost:8080/` + article.image}
                  description={article.content}
                  title={article.title}
                  key={index}
                  link={"#"}
                />{" "}
                <div
                  onClick={() => removeArticle(article.id)}
                  className="absolute right-2 bottom-2 z-30 w-fit h-fit"
                >
                 <MdOutlineAutoDelete className="w-5 h-5 hover:text-red-500" />
                </div>
              </div>
            );
          })}
      </div>

      <div className="w-full py-5  border border-transparent border-t-mv-gray-200">
        <Pagination
          currentPage={currentPage}
          totalPages={paginatedItems.length}
          onPageChange={handlePageChange}
        />
      </div>
    </section>
  );
};
export default ArticleList;
Enter fullscreen mode Exit fullscreen mode

Components/TextEditor.tsx

For the creation of our text editor, we will use the codemirror library.

This library will enable the editor to process Markdown writing.

We start by importing the library and then writing the component.

"use client";
import { useCallback, useEffect } from "react";
import useCodeMirror from "@/lib/use-codemirror";

interface Props {
  initialDock: string;
  onChange: (doc: string) => void;
}

const TextEditor: React.FC<Props> = (props) => {
  const { onChange, initialDock } = props;
  const handleChange = useCallback(
    (state: any) => onChange(state.doc.toString()),
    [onChange],
  );
  const [refContainer, editorView] = useCodeMirror<HTMLDivElement>({
    initialDoc: initialDock,
    onChange: handleChange,
  });
  useEffect(() => {
    if (editorView) {
      console.log(editorView);
    }
  }, [editorView]);

  return (
    <section className="h-full w-full">
      <div
        className="editor-wrapper  h-full w-full mx-auto flex flex-col gap-2"
        ref={refContainer}
      />
    </section>
  );
};

export default TextEditor;
Enter fullscreen mode Exit fullscreen mode

Pages

Next, we will go through each of our pages, divided by their respective routes.

Public Pages

Login

This is the homepage of our application. It is a simple page, and you can modify it as you see fit. On this page, we will use the signin function provided by the next-auth navigation library.

In the file src/app/pages/public/login/page.tsx.

"use client";
import { ChangeEvent, FormEvent, useState } from "react";
import { signIn } from "next-auth/react";

export default function Page() {
  const inputStyle =
    "p-2 border border-1 border-slate-300 rounded-md text-black";

  const [formValues, setFormValues] = useState<{
    email: string;
    password: string;
  }>({
    email: "",
    password: "",
  });

  const handleChange = (e: ChangeEvent<HTMLInputElement>) => {
    const { name, value }: { name: string; value: string } = e.target;
    setFormValues({ ...formValues, [name]: value });
  };

  const onSubmit = async (e: FormEvent<HTMLFormElement>) => {
    e.preventDefault();
    const res = await signIn("credentials", {
      email: formValues.email,
      password: formValues.password,
      redirect: true,
      callbackUrl: `${window.location.origin}/`,
    });
    console.log("res--->", res);
  };

  return (
    <main className="flex flex-col  min-h-screen w-full  px-24">
      <section className="container mx-auto min-h-screen h-full flex items-center justify-center">
        <div className=" bg-white h-[20em] rounded-md p-3 drop-shadow-md ">
          <div className="flex flex-col gap-2 items-center py-5">
            <h1 className="text-blue-500 text-5xl">W</h1>
            <span className="text-slate-400">Entrar em seu blog</span>
          </div>
          <form
            onSubmit={(e: any) => {
              onSubmit(e);
            }}
            className="w-full flex flex-col gap-2"
          >
            <input
              onChange={(e: any) => handleChange(e)}
              name="email"
              type="email"
              placeholder="Email"
              value={formValues.email}
              className={inputStyle}
            />
            <input
              onChange={(e: any) => handleChange(e)}
              name="password"
              type="password"
              placeholder="Password"
              value={formValues.password}
              className={inputStyle}
            />
            <button className="mt-2 border border-slate-400 hover:bg-blue-500 text-blue-500 hover:text-white w-fit p-2 self-start rounded-md   text-[14px] font-openSans ">
              Entrar
            </button>
          </form>
        </div>
      </section>
    </main>
  );
}
Enter fullscreen mode Exit fullscreen mode

Article Page

To create the article reading page, we will develop a dynamic page.

Every blog platform you've visited likely has a dedicated page for reading articles, accessible via URL. The reason for this is a dynamic page route. Fortunately, Next.js makes this easy with its new AppRouter method, making our lives much simpler.

First: we need to create the route in our structure by adding a [id] folder. This will result in the following structure: pages/(public)/articles/[id]/pages.tsx.

  • The id corresponds to the slug of our navigation route.
  • params is a property passed through the tree of our application containing the navigation slug.
export default function Page({ params }: { params: any }) {
  const id: number = params.id;
...
Enter fullscreen mode Exit fullscreen mode

Second: use the MarkdownIt library to enable the page to display text in Markdown format.

import MarkdownIt from "markdown-it";
import "github-markdown-css/github-markdown.css";
Enter fullscreen mode Exit fullscreen mode

And finally,

once the page is ready, by accessing, for example, localhost:3000/articles/1 in the browser, you will be able to view the article with the provided ID.

In our case, the ID will be passed through navigation when clicking on one of the ArticleCards.tsx components, which will be rendered on the main page of our private route.

"use client";
import { useState, useEffect } from "react";
import { IArticle } from "@/interfaces/article.interface";
import Image from "next/image";

import MarkdownIt from "markdown-it";
import "github-markdown-css/github-markdown.css";

export default function Page({ params }: { params: any }) {
  const id: number = params.id;
  const [article, setArticle] = useState<IArticle | null>(null);

  const fetchArticle = async (id: number) => {
    try {
      const response = await fetch(
        `http://localhost:8080/articles/getById/${id}`,
      );
      const jsonData = await response.json();
      setArticle(jsonData);
    } catch (err) {
      console.log("something went wrong:", err);
    }
  };

  useEffect(() => {
    if (article !== null || article !== undefined) {
      fetchArticle(id);
    }
  }, [id, article]);

  if (article === null) return null;

  const md = new MarkdownIt({
    html: true,
    linkify: true,
    typographer: true,
  });

  const mdTitle: string = md.render(article.title);
  const mdContent: string = md.render(article.content);

  return (
    <section className="min-h-screen">
      <section className="w-full container mx-auto">
        <div className="container h-[20em] relative ">
          <Image
            src={`http://localhost:8080/` + article?.image}
            alt="hero image"
            fill={true}
            className="w-full h-full z-1 object-cover bg-center bg-no-repeat"
          />
        </div>
        <>
          <div className="markdown-body p-5">
            <div
              className="text-4xl"
              dangerouslySetInnerHTML={{ __html: mdTitle }}
            />
            <div dangerouslySetInnerHTML={{ __html: mdContent }} />
          </div>
        </>
      </section>
    </section>
  );
}
Enter fullscreen mode Exit fullscreen mode

Private Pages

Here are our private pages, which can only be accessed once the user is authenticated in our application.

Home

Inside our app/pages/ folder, when a file is declared inside (), it means that route corresponds to /.

In our case, the (Home) folder refers to the homepage of our private route. It is the first page the user sees upon authenticating into the system. This page will display the list of articles from our database.

The data will be processed by our ArticlesList.tsx component. If you haven’t written this code yet, refer back to the components section.

In app/(pages)/(private)/(home)/page.tsx.

import ArticleList from "@/components/ArticlesList";
export default function Home() {
  return (
    <main className="flex flex-col  min-h-screen w-full  px-24">
      <section className="w-full h-full min-h-[92vh]">
        <ArticleList />
      </section>
    </main>
  );
}
Enter fullscreen mode Exit fullscreen mode

New Article

This is one of the most important pages of our application, as it allows us to register our articles.

This page will enable the user to:

  1. Write an article in Markdown format.
  2. Assign an image to the article.
  3. Preview the Markdown text before submitting it to the server.

The page uses several hooks:

  1. useCallback - used to memoize functions.
  2. useState - allows you to add a state variable to our component.
  3. useSession - lets us check if the user is authenticated and obtain the authentication token.

For this, we will use two components:

  1. TextEditor.tsx: the text editor we wrote previously.
  2. Preview.tsx: a component for displaying files in Markdown format.

While constructing this page, we will make use of our API:

  1. POST: Using our function postArticle, we will send the article to the server.

We will also use the useSession hook, provided by the next-auth library, to obtain the user's authentication token, which will be used to register the article on the server.

This will involve three distinct API calls.
In app/pages/(private)/newArticle/page.tsx.

"use client";
import React, { ChangeEvent, useCallback, useState } from "react";
import { useSession } from "next-auth/react";
import { redirect } from "next/navigation";
import postArtical from "@/services/postArticle";
import { AiOutlineFolderOpen } from "react-icons/ai";
import { RiImageEditLine } from "react-icons/ri";

import Image from "next/image";
import TextEditor from "@/components/textEditor";
import Preview from "@/components/PreviewText";
import { AiOutlineSend } from "react-icons/ai";
import { BsBodyText } from "react-icons/bs";

export default function NewArticle(params:any) {
  const { data: session }: any = useSession({
    required: true,
    onUnauthenticated() {
      redirect("/login");
    },
  });
  const [imageUrl, setImageUrl] = useState<object>({});
  const [previewImage, setPreviewImage] = useState<string>("");
  const [previewText, setPreviewText] = useState<boolean>(false);
  const [title, setTitle] = useState<string>("");
  const [doc, setDoc] = useState<string>("# Escreva o seu texto... \n");
  const handleDocChange = useCallback((newDoc: any) => {
    setDoc(newDoc);
  }, []);

  if (!session?.user) return null;

  const handleArticleSubmit = async (e:any) => {
        e.preventDefault();
    const token: string = session.user.token;
    try {
      const res = await postArtical({
        id: session.user.userId.toString(),
        token: token,
        imageUrl: imageUrl,
        title: "title,"
        doc: doc,
      });
      console.log('re--->', res);
      redirect('/success');
    } catch (error) {
      console.error('Error submitting article:', error);
      // Handle error if needed
      throw error;
    }
  };

  const handleImageChange = (e: React.ChangeEvent<HTMLInputElement>) => {
    if (e.target.files && e.target.files.length > 0) {
      const file = e.target.files[0];
      const url = URL.createObjectURL(file);
      setPreviewImage(url);
      setImageUrl(file);
    }
  };

  const handleTextPreview = (e: any) => {
    e.preventDefault();
    setPreviewText(!previewText);
  };
  return (
    <section className="w-full h-full min-h-screen relative py-8">
      {previewText && (
        <div className="absolute right-16 top-5 p-5 border-2 border-slate-500 bg-slate-100 rounded-xl w-full max-w-[33em] z-30">
          <Preview
            doc={doc}
            title={title}
            previewImage={previewImage}
            onPreview={() => setPreviewText(!previewText)}
          />
        </div>
      )}

      <form className="relative mx-auto max-w-[700px] h-full min-h-[90%] w-full p-2 border-2 border-slate-200 rounded-md  bg-slate-50 drop-shadow-xl flex flex-col gap-2 ">
        {" "}
        <div className="flex justify-between items-center">
          <button
            className="border-b-2 rounded-md border-slate-500 p-2 flex items-center gap-2  hover:border-slate-400 hover:text-slate-800"
            onClick={handleTextPreview}
          >
            <BsBodyText />
            Preview
          </button>{" "}
          <button
            className="group border border-b-2 border-slate-500 rounded-md p-2 flex items-center gap-2 hover:border-slate-400 hover:text-slate-800 "
            onClick={handleArticleSubmit}
          >
            Enviar Texto
            <AiOutlineSend className="w-5 h-5 group-hover:text-red-500" />
          </button>
        </div>
        <div className="header-wrapper flex flex-col gap-2 ">
          <div className="image-box">
            {previewImage.length === 0 && (
              <div className="select-image">
                <label
                  htmlFor="image"
                  className="p-4 border-dashed border-4 border-slate-400 cursor-pointer flex flex-col items-center justify-center"
                >
                  <AiOutlineFolderOpen className="w-7 h-7" />
                  drang and drop image
                </label>
                <input
                  id="image"
                  name="thumb"
                  type="file"
                  multiple
                  className="w-full h-5"
                  style={{ display: "none" }}
                  onChange={handleImageChange}
                />
              </div>
            )}
            {previewImage.length > 0 && (
              <div className="w-full h-[10em] relative">
                <div className="absolute top-0 left-0 w-full h-full cursor-pointer transition-opacity bg-transparent hover:bg-[#00000036] z-30" />
                <RiImageEditLine className="w-[3em] h-[3em] absolute right-1 z-30 text-slate-300 " />
                <Image
                  alt="prev-image"
                  layout="fill"
                  className="w-[10em] h-[10em] object-cover bg-center bg-no-repeat "
                  src={previewImage}
                />
              </div>
            )}
          </div>

          <div className="flex justify-between w-full">
            <input
              name="title"
              type="text"
              placeholder="Título"
              onChange={(e: ChangeEvent<HTMLInputElement>) =>
                setTitle(e.target.value)
              }
              className="border-x-2 border-b w-full p-2"
            />
          </div>
        </div>
        <TextEditor initialDock={doc} onChange={handleDocChange} />
      </form>
    </section>
  );
}
Enter fullscreen mode Exit fullscreen mode

Edit Article

A page similar to New Article (newArticle), with some differences.

First, we define a dynamic route where we receive an id as a navigation parameter. This is very similar to what was done on the article reading page.
app/(pages)/(private)/editArticle/[id]/page.tsx

"use client";
import React, { useState, useEffect, useCallback, useRef, ChangeEvent } from "react";
import { useSession } from "next-auth/react";
import { redirect } from "next/navigation";
import Image from 'next/image';

import { IArticle } from "@/interfaces/article.interface";
import { AiOutlineEdit } from "react-icons/ai";
import { BsBodyText } from "react-icons/bs";
import { AiOutlineFolderOpen } from "react-icons/ai";
import { RiImageEditLine } from "react-icons/ri";

import Preview from "@/components/PreviewText";
import TextEditor from "@/components/textEditor";
import Loading from '@/components/Loading';
import editArtical from "@/services/editArticle";

export default function EditArticle({ params }: { params: any }) {
 const { data: session }: any = useSession({
    required: true,
    onUnauthenticated() {
      redirect("/login");
    },
  });
  const id: number = params.id;
  const [article, setArticle] = useState<IArticle | null>(null);
  const [imageUrl, setImageUrl] = useState<object>({});
  const [previewImage, setPreviewImage] = useState<string>("");
  const [previewText, setPreviewText] = useState<boolean>(false)
  const [title, setTitle] = useState<string>("");
  const [doc, setDoc] = useState<string>('');
  const handleDocChange = useCallback((newDoc: any) => {
    setDoc(newDoc);
  }, []);
  const inputRef= useRef<HTMLInputElement>(null);

  const fetchArticle = async (id: number) => {
    try {
      const response = await fetch(
        `http://localhost:8080/articles/getById/${id}`,
      );
      const jsonData = await response.json();
      setArticle(jsonData);
    } catch (err) {
      console.log("something went wrong:", err);
    }
  };
  useEffect(() => {
    if (article !== null || article !== undefined) {
      fetchArticle(id);
    }
  }, [id]);

  useEffect(()=>{
    if(article != null && article.content){
        setDoc(article.content)
    }

    if(article !=null && article.image){
      setPreviewImage(`http://localhost:8080/` + article.image)
    }
  },[article])

  const handleArticleSubmit = async (e:any) => {
     e.preventDefault();
    const token: string = session.user.token;
    try{
      const res = await editArtical({
      id: id,
      token: token,
      imageUrl:imageUrl,
      title: title,
      doc: doc,
      });
        console.log('re--->',res)
        return res;
    } catch(error){
    console.log("Error:", error)
    }
  };
  const handleImageClick = ()=>{
      console.log('hiii')
    if(inputRef.current){
      inputRef.current.click();
    }
  }
  const handleImageChange = (e: React.ChangeEvent<HTMLInputElement>) => {
    if (e.target.files && e.target.files.length > 0) {
      const file = e.target.files[0];
      const url = URL.createObjectURL(file);
      setPreviewImage(url);
      setImageUrl(file);
    }

  };
   const handleTextPreview = (e: any) => {
    e.preventDefault();
    setPreviewText(!previewText);
    console.log('hello from preview!')
  };

  if(!article) return <Loading/>
  if(article?.content)
  return (
    <section className='w-full h-full min-h-screen relative py-8'>
      {previewText && (
        <div className="absolute right-16 top-5 p-5 border-2 border-slate-500 bg-slate-100 rounded-xl w-full max-w-[33em] z-30">
          <Preview
            doc={doc}
            title={title}
            previewImage={previewImage}
            onPreview={() => setPreviewText(!previewText)}
          />
        </div>
      )}

      <div className='relative mx-auto max-w-[700px] h-full min-h-[90%] w-full p-2 border-2 border-slate-200 rounded-md  bg-white drop-shadow-md flex flex-col gap-2'>
        <form className='relative mx-auto max-w-[700px] h-full min-h-[90%] w-full p-2 border-2 border-slate-200 rounded-md  bg-slate-50 drop-shadow-md flex flex-col gap-2 '>
          {" "}
          <div className='flex justify-between items-center'>
            <button
              className='border-b-2 rounded-md border-slate-500 p-2 flex items-center gap-2  hover:border-slate-400 hover:text-slate-800'
              onClick={handleTextPreview}
            >
              <BsBodyText />
              Preview
            </button>{" "}
            <button
              className='group border border-b-2 border-slate-500 rounded-md p-2 flex items-center gap-2 hover:border-slate-400 hover:text-slate-800 '
              onClick={handleArticleSubmit}
            >
                Edite artigo 
              <AiOutlineEdit className='w-5 h-5 group-hover:text-red-500' />
            </button>
          </div>
          <div className='header-wrapper flex flex-col gap-2 '>
            <div className='image-box'>
              {previewImage.length === 0 && (
                <div className='select-image'>
                  <label
                    htmlFor='image'
                    className='p-4 border-dashed border-4 border-slate-400 cursor-pointer flex flex-col items-center justify-center'
                  >
                    <AiOutlineFolderOpen className='w-7 h-7' />
                    drang and drop image
                  </label>
                  <input
                    id='image'
                    name='thumb'
                    type='file'
                    multiple
                    className='w-full h-5'
                    style={{ display: "none" }}
                    onChange={handleImageChange}
                  />
                </div>
              )}
              {previewImage.length > 0 && (
                <div className='w-full h-[10em] relative'>
                  <div className='absolute top-0 left-0 w-full h-full cursor-pointer transition-opacity bg-transparent hover:bg-[#00000036] z-30'onClick={handleImageClick} />
                  <RiImageEditLine className='w-[3em] h-[3em] absolute right-1 z-30 text-slate-300' />
                  <Image
                    alt='prev-image'
                    layout='fill'
                    className='w-[10em] h-[10em] object-cover bg-center bg-no-repeat'
                    src={previewImage}
                  />
                  <input
                    id='image'
                    name='thumb'
                    type='file'
                    multiple
                    ref={inputRef}
                    className='w-full h-full' 
                    style={{ display: "none" }}
                    onChange={handleImageChange}
                  />
                </div>
              )}
            </div>

            <div className='flex justify-between w-full'>
              <input
                name='title'
                type='text'
                placeholder='Título'
                defaultValue={article?.title}
                onChange={(e: ChangeEvent<HTMLInputElement>) =>
                  setTitle(e.target.value)
                }
                className='border-x-2 border-b w-full p-2'
              />
            </div>
          </div>
         {doc &&(<TextEditor initialDock={doc} onChange={handleDocChange} />)} 
        </form>
      </div>
    </section>
  );

  else return  null
}
Enter fullscreen mode Exit fullscreen mode

Conclusion

First, I would like to thank you for taking the time to read this tutorial, and I also want to congratulate you on completing it. I hope it served you well and that the step-by-step instructions were easy to follow.

Second, I’d like to highlight a few points about what we just built. This is the foundation of a blog system, and there’s still much to add, such as a public page displaying all articles, a user registration page, or even a custom 404 error page. If, during the tutorial, you wondered about these pages and missed them, know that this was intentional. This tutorial provided you with enough experience to create these new pages on your own, add many others, and implement new features.

Thank you very much, and until next time. o/

Top comments (0)