DEV Community

Cover image for Construindo um Painel de Blog Dinâmico com Next.js
Vitor Alecrim
Vitor Alecrim

Posted on

Construindo um Painel de Blog Dinâmico com Next.js

Apresentação

Olá, como vai? Aqui é o Vítor, retornando com um novo projeto para ajudá-lo a aprimorar suas habilidades de programador. Já faz um tempo desde que publiquei um tutorial. Nos últimos meses, tirei um tempo para descansar e me dedicar a outras atividades. Durante esse período, desenvolvi um pequeno projeto web: um blog, que se tornou o foco deste tutorial.

Neste guia, vamos criar o frontend de uma página de blog capaz de renderizar a escrita markdown. A aplicação incluirá rotas públicas e privadas, autenticação de usuário, e a capacidade de escrever texto em Markdown, com a adição de fotos, exibição de artigos e muito mais.

Sinta-se à vontade para personalizar a sua aplicação da maneira que preferir, até mesmo eu incentivo isso.

Você pode acessar o repositório dessa aplicação aqui:

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




Este tutorial também conta com a escrita do servidor Node.js que será usado nesse tutorial:

Espero que você se divirta.

Bom código.

Bibliotecas

Aqui está o resumo das bibliotecas usadas neste projeto.

Criando projeto React.

Vamos utilizar a versão mais recente do framework Next.js, que, no momento da redação deste tutorial, é a versão 13.4.

Execute o seguinte comando para criar o projeto:

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

Durante a instalação, selecione as configurações do template. Neste tutorial,usarei TypeScript como linguagem e o framework de CSS, Tailwind CSS, para o estilo da nossa aplicação.

Configuração

Agora vamos instalar todas as bibliotecas que faremos uso.

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

Depois limpe a estrutura inicial da sua instalação, jogando fora tudo aquilo que não iremos usar.

Arquitetura

Assim é a estrutura final de nossa aplicação.

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

Primeiros passos

Configurando next.config

Na raiz do projeto, no arquivo next.config.js, vamos configurar o endereço do domínio de onde iremos acessar as imagens dos nossos artigos. Para este tutorial, ou se estiver usando um servidor local, utilizaremos localhost.

Certifique-se de incluir essa configuração para garantir o correto carregamento das imagens em sua aplicação.

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

Configurando Middleware

Na pasta raíz da aplicação src/,crie um middleware.ts para verificar o acesso às rotas privadas.

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

Para saber mais sobre middlewares e tudo o que você pode fazer com ele, acesse a documentação

Configurando Rota de Autenticação.

Dentro da pasta /app, crie um arquivo chamado route.ts em api/auth/[...nextauth]. Ele conterá a configuração de nossas rotas, conectando-se à nossa API de autenticação usando o CredentialsProvider.

O CredentialsProvider permite que você lide com o login usando credenciais arbitrárias, como nome de usuário e senha, domínio ou autenticação de dois fatores ou dispositivo de hardware etc.

Primeiramente, na raiz do seu projeto, crie um arquivo .env.local e adicione um token que será usado como o nosso secret.

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

Em seguida, vamos escrever nosso sistema de autenticação, onde esse NEXTAUTH_SECRET será adicionado ao nosso secret no arquivo src/app/auth/[...nextauth]/routes.ts.

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

Provedor de Autenticação

Vamos criar um provedor de autenticação, um context, que irá compartilhar os dados do nosso usuário pelas páginas da nossa rota privada. Vamos utilizá-lo posteriormente para encapsular um de nossos layout.tsx.

Crie um arquivo em src/context/auth-provider.tsx com o seguinte conteúdo:

'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

Estilo Globais

No geral, em nossa aplicação, usaremos o Tailwind CSS para criar nosso estilo. No entanto, em alguns lugares, iremos compartilhar classes de CSS personalizadas entre páginas e componentes.

/*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

agora vamos escrever os layouts, privados e públcios.

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

Chamadas para a API

Nossa aplicação fará várias chamadas à nossa API, e você pode adaptar essa aplicação para usar qualquer API externa. No nosso exemplo, estamos utilizando a nossa aplicação local. Caso não tenha visto o tutorial do backend e a criação do servidor, acesse.

Em src/services/, vamos escrever as funções abaixo:

  1. authService.ts: função responsável por autenticar nosso usuário no servidor.
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: função responsável por chamar todos os artigos salvos em nosso banco de dados:
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. postArtcile.tsx: função responsável por registrar os dados do artigo em nosso servidor.
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: função responsável por modificar um artigo específico dentro do banco de dados.
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: função responsável por remover um artigo específico de nosso banco de dados:
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

Componentes

A seguir, vamos escrever cada componente usado durante a aplicação.

Components/Navbar.tsx

Um componente simples com dois links de navegação.

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

Um componente simples de loading, usado durante a espera das chamadas de API.

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

Um componente de paginação usado em nossa página de exibição de todos os nossos artigos, em nossa rota privada. Você pode encontrar um artigo mais detalhado sobre a escrita deste componenteaqui

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

Componente botão para retirada de usuário da aplicação.

"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

Cartão de exibição dos artigos escritos.
Este componente também contém um link que levará tanto à página de exibição do artigo quanto à página de edição de um artigo previamente escrito.

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

Componente responsável por exibir o texto que estamos escrevendo em nosso editor Ele faz uso de uma biblioteca diferente da article. Caso queira, você pode adaptar o componente para usar a mesma biblioteca.

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

Componente responsável por executar chamadas de api e a exibição do retorno de sua resposta.
Aqui faremos uso de duas chamadas de api através das funções que escrevemos:

  1. getArticles.ts - nos retorna todos os artigos que serão exibidos no componente.
  2. removeArticle - remove um artigo específico de nossa lista e do nosso servidor.

Faremos uso do componente Pagination.tsx, escrito previamente para dividir o número de nossos artigos em páginas.

"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