你好,你好吗?我是 Vítor,带着一个新项目回来了,可以帮助您提高编程技能。自从我上次发布教程以来已经有一段时间了。在过去的几个月里,我花了一些时间休息并专注于其他活动。在此期间,我开发了一个小型网络项目:博客,它成为本教程的重点。
在本指南中,我们将创建能够渲染 Markdown 的博客页面的前端。该应用程序将包括公共和私人路由、用户身份验证以及编写 Markdown 文本、添加照片、显示文章等功能。
随意定制您的应用程序,无论您喜欢什么——我什至鼓励这样做。
您可以在此处访问此应用程序的存储库:
npm i npm run start
você pode encontrar o server dessa aplicação em server
本教程还包括本指南中将使用的 Node.js 服务器的编写:
希望您喜欢。
编码愉快!
以下是此项目中使用的库的摘要:
我们将使用最新版本的 Next.js 框架,在编写本教程时,版本为 13.4。
运行以下命令来创建项目:
npm i npm run start
安装过程中,选择模板设置。在本教程中,我将使用 TypeScript 作为编程语言,并使用 Tailwind CSS 框架来设计我们的应用程序。
现在让我们安装我们将使用的所有库。
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
然后通过删除我们不会使用的所有内容来清理安装的初始结构。
这是我们应用程序的最终结构。
npm i react-icons @types/react-icons
在项目根目录的 next.config.js 文件中,让我们配置用于访问文章图像的域地址。对于本教程,或者如果您使用本地服务器,我们将使用 localhost。
确保包含此配置以确保在应用程序中正确加载图像。
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
在应用程序 src/ 的根文件夹中,创建一个 middleware.ts 以验证对私有路由的访问。
const nextConfig = { images: { domains: ["localhost"], }, };
要了解有关中间件以及可以使用它们执行的所有操作的更多信息,请查看文档。
在 /app 文件夹内,在 api/auth/[...nextauth] 中创建一个名为 Route.ts 的文件。它将包含我们的路由配置,使用 CredentialsProvider 连接到我们的身份验证 API。
CredentialsProvider 允许您处理使用任意凭据的登录,例如用户名和密码、域、双因素身份验证、硬件设备等。
首先,在项目的根目录中,创建一个 .env.local 文件并添加一个令牌,该令牌将用作我们的秘密。
npm i npm run start
接下来,让我们编写我们的身份验证系统,这个 NEXTAUTH_SECRET 将被添加到 src/app/auth/[...nextauth]/routes.ts 文件中的秘密中。
npx create-next-app myblog
让我们创建一个身份验证提供程序,一个上下文,它将在我们的私有路由的页面上共享用户的数据。稍后我们将使用它来包装我们的layout.tsx 文件之一。
在 src/context/auth-provider.tsx 中创建一个包含以下内容的文件:
npm i markdown-it @types/markdown-it markdown-it-style github-markdown-css react-markdown
总的来说,在我们的应用程序中,我们将使用 Tailwind CSS 来创建我们的样式。但是,在某些地方,我们将在页面和组件之间共享自定义 CSS 类。
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
npm i react-icons @types/react-icons
我们的应用程序将多次调用我们的 API,您可以调整此应用程序以使用任何外部 API。在我们的示例中,我们使用本地应用程序。如果你还没有看过后端教程和服务器创建,请查看。
在 src/services/ 中,我们编写以下函数:
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> ) };
接下来,让我们编写整个应用程序中使用的每个组件。
一个带有两个导航链接的简单组件。
/*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; }
一个简单的加载组件,在等待 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> ); }
我们页面上使用的分页组件,在我们的私有路径中显示我们的所有文章。您可以在这里找到有关如何编写此组件的更详细的文章
npm i npm run start
用于显示书面文章的卡片组件。
该组件还包含一个链接,该链接将指向文章显示页面和编辑先前撰写的文章的页面。
npx create-next-app myblog
负责进行 API 调用并显示响应的组件。
在这里,我们将通过我们编写的函数使用两个 API 调用:
我们将使用之前编写的 Pagination.tsx 组件来跨页面分割文章数量。
npm i markdown-it @types/markdown-it markdown-it-style github-markdown-css react-markdown
接下来,我们将按各自的路线划分浏览每个页面。
这是我们应用程序的主页。这是一个简单的页面,您可以根据需要对其进行修改。在这个页面中,我们将使用next-auth导航库提供的登录功能。
在文件 src/app/pages/public/login/page.tsx 中。
remark remark-gfm remark-react
为了创建文章阅读页面,我们将开发一个动态页面。
您访问过的每个博客平台可能都有一个用于阅读文章的专用页面,可通过 URL 访问。其原因是动态页面路由。幸运的是,Next.js 通过其新的 AppRouter 方法使这一切变得简单,使我们的生活变得更加简单。
首先:我们需要通过添加 [id] 文件夹在结构中创建路由。这将产生以下结构: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
第二:使用MarkdownIt库,使页面能够显示Markdown格式的文本。
npm i react-icons @types/react-icons
最后,
页面准备好后,例如通过在浏览器中访问 localhost:3000/articles/1,您将能够使用提供的 ID 查看文章。
在我们的例子中,当单击其中一个 ArticleCards.tsx 组件时,ID 将通过导航传递,该组件将呈现在我们的私有路由的主页上。
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
这是我们的私人页面,只有用户在我们的应用程序中通过身份验证后才能访问。
在我们的app/pages/文件夹中,当在()内声明一个文件时,就意味着该路由对应于/。
在我们的例子中,(Home) 文件夹指的是我们私人路线的主页。这是用户在系统中进行身份验证后看到的第一个页面。此页面将显示我们数据库中的文章列表。
数据将由我们的 ArticlesList.tsx 组件处理。如果您还没有编写此代码,请参阅组件部分。
在应用程序/(页面)/(私人)/(主页)/page.tsx中。
npm i npm run start
这是我们应用程序中最重要的页面之一,因为它允许我们注册我们的文章。
此页面将使用户能够:
页面使用了多个钩子:
为此,我们将使用两个组件:
在构建此页面时,我们将使用我们的 API:
我们还将使用 next-auth 库提供的 useSession 钩子来获取用户的身份验证令牌,该令牌将用于在服务器上注册文章。
这将涉及三个不同的 API 调用。
在 app/pages/(private)/newArticle/page.tsx.
“使用客户端”; 从“react”导入 React, { ChangeEvent, useCallback, useState }; 从“next-auth/react”导入{useSession}; 从“下一步/导航”导入{重定向}; 从“@/services/postArticle”导入 postArtical; 从“react-icons/ai”导入{AiOutlineFolderOpen}; 从“react-icons/ri”导入 { RiImageEditLine }; 从“下一个/图像”导入图像; 从“@/components/textEditor”导入文本编辑器; 从“@/components/PreviewText”导入预览; 从“react-icons/ai”导入{AiOutlineSend}; 从“react-icons/bs”导入{BsBodyText}; 导出默认函数 NewArticle(params:any) { const { 数据:会话 }:任何 = useSession({ 要求:真实, onUnauthenticated(){ 重定向(“/登录”); }, }); const [imageUrl, setImageUrl] = useState<object>({}); const [previewImage, setPreviewImage] = useState<string>(""); const [previewText, setPreviewText] = useState<boolean>(false); const [标题,setTitle] = useState<string>(""); const [doc, setDoc] = useState<string>("# Escreva o seu texto... n"); const handleDocChange = useCallback((newDoc: any) => { setDoc(newDoc); }, []); if (!session?.user) 返回 null; const handleArticleSubmit = async (e:any) =>; { e.preventDefault(); const token: string = session.user.token; 尝试 { const res = 等待 postArtical({ id: session.user.userId.toString(), 令牌:令牌, 图片网址: 图片网址, 标题:“标题” 文档: 文档, }); console.log('re--->', res); 重定向('/成功'); } 捕获(错误){ console.error('提交文章时出错:', error); // 如果需要,处理错误 抛出错误; } }; const handleImageChange = (e: React.ChangeEvent<htmlinputelement>) =>; { if (e.target.files && e.target.files.length > 0) { const 文件 = e.target.files[0]; const url = URL.createObjectURL(文件); 设置预览图像(网址); setImageUrl(文件); } }; const handleTextPreview = (e: 任意) => { e.preventDefault(); setPreviewText(!previewText); }; 返回 ( <section classname="w-full h-full min-h-screenrelative py-8"> {预览文本&&( <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">; ; 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 间隙-2“> {“”} <div className="flex justify- Between items-center"> <按钮 className =“border-b-2 rounded-md border-slate-500 p-2 flex items-center差距-2悬停:border-slate-400悬停:text-slate-800” onClick={handleTextPreview} > <BsBodyText/>> 预览 </按钮>{" onclick="{handleArticleSubmit}"> 恩维亚尔·特克斯托 <AiOutlineSend className="w-5 h-5 group-hover:text-red-500" /> </按钮> ; <div className="header-wrapper flex flex-col gap-2"> <div className="image-box"> {previewImage.length === 0 && ( <div className="select-image"> <aioutlinefolderopen classname="w-7 h-7"></aioutlinefolderopen> 拖放图像 </标签> <h4> 编辑文章 </h4> <p>与<em>新文章</em>(newArticle)类似的页面,但有一些差异。</p> <p>首先,我们定义一条动态路线,在其中接收 id 作为导航参数。这与文章阅读页面上所做的非常相似。 <br> app/(pages)/(private)/editArticle/[id]/page.tsx<br> </p> <pre class="brush:php;toolbar:false">“使用客户端”; 从“react”导入 React, { useState, useEffect, useCallback, useRef, ChangeEvent }; 从“next-auth/react”导入{useSession}; 从“下一步/导航”导入{重定向}; 从“下一个/图像”导入图像; 从“@/interfaces/article.interface”导入{IArticle}; 从“react-icons/ai”导入{AiOutlineEdit}; 从“react-icons/bs”导入{BsBodyText}; 从“react-icons/ai”导入{AiOutlineFolderOpen}; 从“react-icons/ri”导入 { RiImageEditLine }; 从“@/components/PreviewText”导入预览; 从“@/components/textEditor”导入文本编辑器; 从'@/components/Loading'导入加载; 从“@/services/editArticle”导入 editArtical; 导出默认函数 EditArticle({ params }: { params: any }) { const { 数据:会话 }:任何 = useSession({ 要求:真实, onUnauthenticated(){ 重定向(“/登录”); }, }); const id: 数字 = params.id; const [文章,setArticle] = useState<iarticle>(空); const [imageUrl, setImageUrl] = useState<object>({}); const [previewImage, setPreviewImage] = useState<string>(""); const [previewText, setPreviewText] = useState<boolean>(false) const [标题,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) =>; { 尝试 { 常量响应 = 等待获取( `http://localhost:8080/articles/getById/${id}`, ); const jsonData = 等待响应.json(); setArticle(jsonData); } 捕获(错误){ console.log("出了点问题:", err); } }; useEffect(() => { if (文章 !== null || 文章 !== 未定义) { 获取文章(id); } }, [ID]); useEffect(()=>{ if(文章!= null && 文章.内容){ setDoc(文章.内容) } if(文章!=null && 文章.image){ setPreviewImage(`http://localhost:8080/` 文章.image) } },[文章]) const handleArticleSubmit = async (e:any) =>; { e.preventDefault(); const token: string = session.user.token; 尝试{ const res = 等待 editArtical({ 身份证号: 身份证号, 令牌:令牌, 图片网址:图片网址, 标题: 标题, 文档: 文档, }); console.log('re--->',res) 返回资源; } 捕获(错误){ console.log(“错误:”,错误) } }; 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 文件 = e.target.files[0]; const url = URL.createObjectURL(文件); 设置预览图像(网址); setImageUrl(文件); } }; const handleTextPreview = (e: 任意) => { e.preventDefault(); setPreviewText(!previewText); console.log('预览版你好!') }; if(!article) return > if(文章?.内容) 返回 ( <section className='w-full h-full min-h-screenrelative py-8'> {预览文本&&( <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">; ; 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 间隙-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 间隙-2 "> {“”} <div classname="flex justify- Between items-center">; <bsbodytext></bsbodytext>> 预览 </按钮>{" "} 编辑阿蒂戈 <aioutlineedit classname="w-5 h-5 group-hover:text-red-500"></aioutlineedit>> </按钮> </div>; <div classname="header-wrapper flex flex-col gap-2">; <div classname="image-box">; {previewImage.length === 0 && ( <div classname="select-image">; <aioutlinefolderopen classname="w-7 h-7"></aioutlinefolderopen>> 拖放图像 </标签> <h2> 结论 </h2> <p>首先,我要感谢您花时间阅读本教程,并且我还要祝贺您完成它。我希望它对您有帮助,并且分步说明很容易遵循。</p> <p>其次,我想强调一下关于我们刚刚构建的内容的几点。这是博客系统的基础,还有很多东西需要添加,比如显示所有文章的公共页面、用户注册页面,甚至是自定义的 404 错误页面。如果在教程期间您对这些页面感到好奇并错过了它们,请知道这是故意的。本教程为您提供了足够的经验来自行创建这些新页面、添加许多其他页面以及实现新功能。</p> <p>非常感谢,下次再见。哦/</p> </div> </div> </div> </form> </div></htmlinputelement></htmlinputelement></string></string></boolean></string></object></iarticle>
以上是使用 Next.js 构建动态博客仪表板的详细内容。更多信息请关注PHP中文网其他相关文章!