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:
Gondrak08
/
blog-platform
A blog plataform made with Next.js/typescript.
Plataforma para blog
Ingredientes
- next-auth - biblioteca de autenticação para Next.js
- github.com/markdown-it/markdown-it - markdown biblioteca.
- github.com/sindresorhus/github-markdown-css- Para dar estilo ao nosso editor markdown.
- github.com/remarkjs/react-markdown - Biblioteca para renderizar markdown em nosso componente react.
- github.com/remarkjs/remark-react/tree/4722bdf - Plugin para transformar Markdown em React.
- codemirror.net - Editor componente para web.
- react-icons - lib de icones para react.
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.
- next-auth - biblioteca de autenticação para Next.js
- github.com/markdown-it/markdown-it - markdown biblioteca.
- github.com/sindresorhus/github-markdown-css- Para dar estilo ao nosso editor markdown.
- github.com/remarkjs/react-markdown - Biblioteca para renderizar markdown em nosso componente react.
- github.com/remarkjs/remark-react/tree/4722bdf - Plugin para transformar Markdown em React.
- codemirror.net - Editor componente para web.
- react-icons - lib de icones para react.
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
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
React Remark
remark remark-gfm remark-react
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
Icons
npm i react-icons @types/react-icons
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
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"],
},
};
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*"],
};
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
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 };
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>
)
};
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;
}
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>
);
}
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>
</>
);
}
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:
-
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;
};
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;
}
}
-
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);
}
}
-
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);
}
}
-
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;
}
}
-
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);
}
}
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>
);
}
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>
);
}
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>
);
}
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;
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>
);
}
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;
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:
-
getArticles.ts- nos retorna todos os artigos que serão exibidos no componente. -
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