Maison >interface Web >js tutoriel >Créer un tableau de bord de blog dynamique avec Next.js
Bonjour, comment vas-tu ? Voici Vítor, qui revient avec un nouveau projet pour vous aider à améliorer vos compétences en programmation. Cela fait un moment que je n'ai pas publié de tutoriel. Au cours des derniers mois, j'ai pris du temps pour me reposer et me concentrer sur d'autres activités. Durant cette période, j'ai développé un petit projet web : un blog, qui est devenu l'objet de ce tutoriel.
Dans ce guide, nous allons créer le frontend d'une page de blog capable de rendre du Markdown. L'application comprendra des itinéraires publics et privés, l'authentification des utilisateurs et la possibilité d'écrire du texte Markdown, d'ajouter des photos, d'afficher des articles et bien plus encore.
N'hésitez pas à personnaliser votre candidature comme vous le souhaitez, je l'encourage même.
Vous pouvez accéder au référentiel de cette application ici :
npm i npm run start
vous pouvez trouver le serveur de votre application sur le serveur
Ce tutoriel comprend également l'écriture du serveur Node.js qui sera utilisé dans ce guide :
J'espère que vous l'apprécierez.
Bon codage !
Voici un résumé des bibliothèques utilisées dans ce projet :
Nous utiliserons la dernière version du framework Next.js, qui, au moment de la rédaction de ce tutoriel, est la version 13.4.
Exécutez la commande suivante pour créer le projet :
npm i npm run start
Lors de l'installation, sélectionnez les paramètres du modèle. Dans ce tutoriel, j'utiliserai TypeScript comme langage de programmation et le framework CSS Tailwind pour styliser notre application.
Installons maintenant toutes les bibliothèques que nous utiliserons.
npx create-next-app myblog
npm i markdown-it @types/markdown-it markdown-it-style github-markdown-css react-markdown
remark remark-gfm remark-react
npm @codemirror/commands @codemirror/highlight @codemirror/lang-javascript @codemirror/lang-markdown @codemirror/language @codemirror/language-data @codemirror/state @codemirror/theme-one-dark @codemirror/view
Nettoyez ensuite la structure initiale de votre installation en supprimant tout ce que nous n'utiliserons pas.
Voici la structure finale de notre application.
npm i react-icons @types/react-icons
A la racine du projet, dans le fichier next.config.js, configurons l'adresse de domaine à partir de laquelle nous accéderons aux images de nos articles. Pour ce tutoriel, ou si vous utilisez un serveur local, nous utiliserons localhost.
Assurez-vous d'inclure cette configuration pour garantir le bon chargement des images dans votre 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
Dans le dossier racine de l'application src/, créez un middleware.ts pour vérifier l'accès aux routes privées.
const nextConfig = { images: { domains: ["localhost"], }, };
Pour en savoir plus sur les middlewares et tout ce que vous pouvez faire avec eux, consultez la documentation.
Dans le dossier /app, créez un fichier nommé route.ts dans api/auth/[...nextauth]. Il contiendra la configuration de nos routes, se connectant à notre API d'authentification à l'aide du CredentialsProvider.
Le CredentialsProvider vous permet de gérer la connexion avec des informations d'identification arbitraires, telles que le nom d'utilisateur et le mot de passe, le domaine, l'authentification à deux facteurs, le périphérique matériel, etc.
Tout d'abord, à la racine de votre projet, créez un fichier .env.local et ajoutez un token qui sera utilisé comme notre secret.
npm i npm run start
Ensuite, écrivons notre système d'authentification, où ce NEXTAUTH_SECRET sera ajouté à notre secret dans le fichier src/app/auth/[...nextauth]/routes.ts.
npx create-next-app myblog
Créons un fournisseur d'authentification, un contexte, qui partagera les données de nos utilisateurs à travers les pages de notre parcours privé. Nous l'utiliserons plus tard pour envelopper l'un de nos fichiers layout.tsx.
Créez un fichier dans src/context/auth-provider.tsx avec le contenu suivant :
npm i markdown-it @types/markdown-it markdown-it-style github-markdown-css react-markdown
Dans l'ensemble, dans notre application, nous utiliserons Tailwind CSS pour créer notre style. Cependant, à certains endroits, nous partagerons des classes CSS personnalisées entre les pages et les composants.
remark remark-gfm remark-react
Écrivons maintenant les mises en page, à la fois privées et publiques.
npm @codemirror/commands @codemirror/highlight @codemirror/lang-javascript @codemirror/lang-markdown @codemirror/language @codemirror/language-data @codemirror/state @codemirror/theme-one-dark @codemirror/view
npm i react-icons @types/react-icons
Notre application effectuera plusieurs appels à notre API, et vous pourrez adapter cette application pour utiliser n'importe quelle API externe. Dans notre exemple, nous utilisons notre application locale. Si vous n'avez pas vu le tutoriel backend et la création du serveur, consultez-le.
Dans src/services/, écrivons les fonctions suivantes :
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
2.refreshAccessToken.tsx :
const nextConfig = { images: { domains: ["localhost"], }, };
export { default } from "next-auth/middleware"; export const config = { matcher: ["/", "/newArticle/", "/article/", "/article/:path*"], };
.env.local NEXTAUTH_SECRET = SubsTituaPorToken
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 };
'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> ) };
Ensuite, écrivons chaque composant utilisé dans l'application.
Un composant simple avec deux liens de navigation.
/*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; }
Un composant de chargement simple, utilisé en attendant la fin des appels d'API.
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> ); }
Un composant de pagination utilisé sur notre page affichant l'ensemble de nos articles, dans notre parcours privé. Vous pouvez trouver un article plus détaillé sur la façon d'écrire ce composant ici
npm i npm run start
Un composant de carte pour afficher des articles écrits.
Ce composant contient également un lien qui mènera à la fois à la page d'affichage de l'article et à la page d'édition d'un article préalablement rédigé.
npx create-next-app myblog
Un composant chargé d'effectuer des appels API et d'afficher la réponse.
Ici, nous utiliserons deux appels API via les fonctions que nous avons écrites :
Nous utiliserons le composant Pagination.tsx, écrit précédemment, pour répartir le nombre d'articles sur les pages.
npm i markdown-it @types/markdown-it markdown-it-style github-markdown-css react-markdown
Ensuite, nous passerons en revue chacune de nos pages, divisées selon leurs itinéraires respectifs.
Voici la page d'accueil de notre application. Il s’agit d’une page simple, et vous pouvez la modifier comme bon vous semble. Sur cette page, nous utiliserons la fonction de connexion fournie par la bibliothèque de navigation next-auth.
Dans le fichier src/app/pages/public/login/page.tsx.
remark remark-gfm remark-react
Pour créer la page de lecture de l'article, nous allons développer une page dynamique.
Chaque plateforme de blog que vous avez visitée possède probablement une page dédiée à la lecture d'articles, accessible via une URL. La raison en est un itinéraire de page dynamique. Heureusement, Next.js facilite cela grâce à sa nouvelle méthode AppRouter, qui nous simplifie grandement la vie.
Premièrement : nous devons créer la route dans notre structure en ajoutant un dossier [id]. Cela donnera la structure suivante : pages/(public)/articles/[id]/pages.tsx.
npm @codemirror/commands @codemirror/highlight @codemirror/lang-javascript @codemirror/lang-markdown @codemirror/language @codemirror/language-data @codemirror/state @codemirror/theme-one-dark @codemirror/view
Deuxième : utilisez la bibliothèque MarkdownIt pour permettre à la page d'afficher le texte au format Markdown.
npm i react-icons @types/react-icons
Et enfin,
une fois la page prête, en accédant par exemple à localhost:3000/articles/1 dans le navigateur, vous pourrez visualiser l'article avec l'ID fourni.
Dans notre cas, l'ID sera transmis via la navigation en cliquant sur l'un des composants ArticleCards.tsx, qui sera rendu sur la page principale de notre itinéraire privé.
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
Voici nos pages privées, accessibles uniquement une fois l'utilisateur authentifié dans notre application.
Dans notre dossier app/pages/, lorsqu'un fichier est déclaré à l'intérieur de (), cela signifie que la route correspond à /.
Dans notre cas, le dossier (Accueil) fait référence à la page d'accueil de notre parcours privé. Il s'agit de la première page que l'utilisateur voit lors de son authentification dans le système. Cette page affichera la liste des articles de notre base de données.
Les données seront traitées par notre composant ArticlesList.tsx. Si vous n'avez pas encore écrit ce code, reportez-vous à la section composants.
Dans app/(pages)/(privé)/(accueil)/page.tsx.
npm i npm run start
C'est l'une des pages les plus importantes de notre application, car elle nous permet d'enregistrer nos articles.
Cette page permettra à l'utilisateur de :
La page utilise plusieurs hooks :
Pour cela, nous utiliserons deux composants :
Lors de la construction de cette page, nous utiliserons notre API :
Nous utiliserons également le hook useSession, fourni par la bibliothèque next-auth, pour obtenir le jeton d'authentification de l'utilisateur, qui servira à enregistrer l'article sur le serveur.
Cela impliquera trois appels API distincts.
Dans app/pages/(private)/newArticle/page.tsx.
"utiliser le client" ; importer React, { ChangeEvent, useCallback, useState } depuis "react" ; importer { useSession } depuis "next-auth/react" ; importer {redirection} depuis "suivant/navigation" ; importer postArtical depuis "@/services/postArticle" ; importer { AiOutlineFolderOpen } depuis "react-icons/ai" ; importer { RiImageEditLine } depuis "react-icons/ri" ; importer l'image depuis "suivant/image" ; importer TextEditor depuis "@/components/textEditor" ; importer l'aperçu depuis "@/components/PreviewText" ; importer { AiOutlineSend } depuis "react-icons/ai" ; importer { BsBodyText } depuis "react-icons/bs" ; fonction d'exportation par défaut NewArticle(params:any) { const { data : session } : any = useSession ({ requis : vrai, onUnauthenticated() { redirect("/connexion"); }, }); const [imageUrl, setImageUrl] = useState<object>({}); const [previewImage, setPreviewImage] = useState<string>(""); const [previewText, setPreviewText] = useState<boolean>(false); const [titre, setTitle] = useState<string>(""); const [doc, setDoc] = useState<string>("# Écrivez votre texte... n"); const handleDocChange = useCallback((newDoc: any) => { setDoc(nouveauDoc); }, []); if (!session?.user) renvoie null ; const handleArticleSubmit = async (e:any) => { e.preventDefault(); jeton const : chaîne = session.user.token ; essayer { const res = attendre postArtical({ identifiant : session.user.userId.toString(), jeton : jeton, URLimage : URLimage, titre : "titre" doc : doc, }); console.log('re--->', res); redirect('/succès'); } attraper (erreur) { console.error('Erreur lors de la soumission de l'article :', erreur); // Gérer l'erreur si nécessaire erreur de lancement ; } } ; const handleImageChange = (e: React.ChangeEvent<HTMLInputElement>) => { if (e.target.files && e.target.files.length > 0) { const fichier = e.target.files[0]; const url = URL.createObjectURL(fichier); setPreviewImage(url); setImageUrl(fichier); } } ; const handleTextPreview = (e: any) => { e.preventDefault(); setPreviewText(!previewText); } ; retour ( <section className="w-full h-full min-h-screen relatif py-8"> {previewText && ( <div className="absolute right-16 top-5 p-5 border-2 border-slate-500 bg-slate-100 arrondi-xl w-full max-w-[33em] z-30"> <Aperçu doc={doc} titre={titre} aperçuImage={aperçuImage} 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 arrondi-md bg-slate-50 drop-shadow-xl flex flex-col écart-2 "> {" "} <div className="flex justifier-entre les éléments-centre"> <bouton className="border-b-2 arrondi-md border-slate-500 p-2 éléments flexibles-center gap-2 survol :border-slate-400 survol :text-slate-800" onClick={handleTextPreview} > <BsBodyText /> Aperçu </bouton>{" "} <bouton className="group border border-b-2 border-slate-500 arrondi-md p-2 flex items-center gap-2 hover:border-slate-400 hover:text-slate-800 " onClick={handleArticleSubmit} > Envoyer le texte <AiOutlineSend className="w-5 h-5 group-hover:text-red-500" /> </bouton> </div> <div className="header-wrapper flex flex-col gap-2 "> <div className="image-box"> {previewImage.length === 0 && ( <div className="select-image"> <étiquette htmlPour="image" className="p-4 border-dashed border-4 border-slate-400 curseur-pointeur flex flex-col items-center justifier-centre" > <AiOutlineFolderOpen className="w-7 h-7" /> glisser et déposer l'image </étiquette> <entrée > <h4> Modifier l'article </h4> <p>Une page similaire au <em>Nouvel Article</em> (newArticle), avec quelques différences.</p> <p>Tout d'abord, nous définissons un itinéraire dynamique où nous recevons un identifiant comme paramètre de navigation. Ceci est très similaire à ce qui a été fait sur la page de lecture de l’article. <br> app/(pages)/(private)/editArticle/[id]/page.tsx<br> </p><pre class="brush:php;toolbar:false">"utiliser le client" ; importer React, { useState, useEffect, useCallback, useRef, ChangeEvent } depuis « react » ; importer { useSession } depuis "next-auth/react" ; importer {redirection} depuis "suivant/navigation" ; importer l'image depuis 'suivant/image' ; importer { IArticle } depuis "@/interfaces/article.interface" ; importer { AiOutlineEdit } depuis "react-icons/ai" ; importer { BsBodyText } depuis "react-icons/bs" ; importer { AiOutlineFolderOpen } depuis "react-icons/ai" ; importer { RiImageEditLine } depuis "react-icons/ri" ; importer l'aperçu depuis "@/components/PreviewText" ; importer TextEditor depuis "@/components/textEditor" ; importer le chargement depuis '@/components/Loading' ; importer editArtical depuis "@/services/editArticle" ; fonction d'exportation par défaut EditArticle({ params }: { params: any }) { const { data : session } : any = useSession ({ requis : vrai, onUnauthenticated() { redirect("/connexion"); }, }); identifiant const : nombre = params.id ; const [article, setArticle] = useState<IArticle | nul>(nul); const [imageUrl, setImageUrl] = useState<object>({}); const [previewImage, setPreviewImage] = useState<string>(""); const [previewText, setPreviewText] = useState<boolean>(false) const [titre, setTitle] = useState<string>(""); const [doc, setDoc] = useState<string>(''); const handleDocChange = useCallback((newDoc: any) => { setDoc(nouveauDoc); }, []); const inputRef= useRef<HTMLInputElement>(null); const fetchArticle = async (id : numéro) => { essayer { réponse const = attendre la récupération ( `http://localhost:8080/articles/getById/${id}`, ); const jsonData = attendre réponse.json(); setArticle(jsonData); } attraper (erreur) { console.log("quelque chose s'est mal passé :", euh); } } ; useEffect(() => { if (article !== null || article !== non défini) { fetchArticle(id); } }, [identifiant]); useEffect(()=>{ si(article != null && article.content){ setDoc(article.content) } si(article !=null && article.image){ setPreviewImage(`http://localhost:8080/` article.image) } },[article]) const handleArticleSubmit = async (e:any) => { e.preventDefault(); jeton const : chaîne = session.user.token ; essayer{ const res = attendre editArtical({ je l'ai fait, jeton : jeton, URLimage :URLimage, titre : titre, doc : doc, }); console.log('re--->',res) retourner la résolution ; } capture (erreur) { console.log("Erreur :", erreur) } } ; const handleImageClick = ()=>{ console.log('hiii') si(inputRef.current){ inputRef.current.click(); } }const handleImageChange = (e: React.ChangeEvent<HTMLInputElement>) => { if (e.target.files && e.target.files.length > 0) { const fichier = e.target.files[0]; const url = URL.createObjectURL(fichier); setPreviewImage(url); setImageUrl(fichier); } } ; const handleTextPreview = (e: any) => { e.preventDefault(); setPreviewText(!previewText); console.log('bonjour depuis l'aperçu !') } ; if(!article) renvoie <Chargement/> si(article?.contenu) retour ( <section className='w-full h-full min-h-screen relatif py-8'> {previewText && ( <div className="absolute right-16 top-5 p-5 border-2 border-slate-500 bg-slate-100 arrondi-xl w-full max-w-[33em] z-30"> <Aperçu doc={doc} titre={titre} aperçuImage={aperçuImage} 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 arrondi-md bg-white drop- shadow-md flex flex-col écart-2'> <form className='relative mx-auto max-w-[700px] h-full min-h-[90%] w-full p-2 border-2 border-slate-200 arrondi-md bg-slate-50 drop-shadow-md flex flex-col écart-2 '> {" "} <div className='flex justifier-entre les éléments-centre'> <bouton className='border-b-2 arrondi-md border-slate-500 p-2 flex items-center gap-2 survol :border-slate-400 survol :text-slate-800' onClick={handleTextPreview} > <BsBodyText /> Aperçu </bouton>{" "} <bouton className='group border border-b-2 border-slate-500 arrondi-md p-2 flex items-center gap-2 hover:border-slate-400 hover:text-slate-800 ' onClick={handleArticleSubmit} > Editer l'article <AiOutlineEdit className='w-5 h-5 group-hover:text-red-500' /> </bouton> </div> <div className='header-wrapper flex flex-col gap-2 '> <div className='image-box'> {previewImage.length === 0 && ( <div className='select-image'> <étiquette htmlFor='image' className = 'p-4 border-dashed border-4 border-slate-400 curseur-pointeur flex flex-col éléments-centre justifier-centre' > <AiOutlineFolderOpen className='w-7 h-7' /> glisser et déposer l'image </étiquette> <entrée > <h2> Conclusion </h2> <p>Tout d'abord, je tiens à vous remercier d'avoir pris le temps de lire ce tutoriel, et je tiens également à vous féliciter de l'avoir terminé. J'espère que cela vous a bien servi et que les instructions étape par étape ont été faciles à suivre.</p> <p>Deuxièmement, j'aimerais souligner quelques points sur ce que nous venons de construire. C'est la base d'un système de blog, et il y a encore beaucoup à ajouter, comme une page publique affichant tous les articles, une page d'enregistrement des utilisateurs ou même une page d'erreur 404 personnalisée. Si, au cours du tutoriel, vous vous êtes interrogé sur ces pages et que vous les avez manquées, sachez que c'était intentionnel. Ce tutoriel vous a fourni suffisamment d'expérience pour créer vous-même ces nouvelles pages, en ajouter bien d'autres et implémenter de nouvelles fonctionnalités.</p> <p>Merci beaucoup, et à la prochaine fois. o/</p>
Ce qui précède est le contenu détaillé de. pour plus d'informations, suivez d'autres articles connexes sur le site Web de PHP en chinois!