首页 > web前端 > js教程 > 使用 Next.js 构建动态博客仪表板

使用 Next.js 构建动态博客仪表板

Barbara Streisand
发布: 2024-12-08 17:04:10
原创
637 人浏览过

介绍

你好,你好吗?我是 Vítor,带着一个新项目回来了,可以帮助您提高编程技能。自从我上次发布教程以来已经有一段时间了。在过去的几个月里,我花了一些时间休息并专注于其他活动。在此期间,我开发了一个小型网络项目:博客,它成为本教程的重点。

在本指南中,我们将创建能够渲染 Markdown 的博客页面的前端。该应用程序将包括公共和私人路由、用户身份验证以及编写 Markdown 文本、添加照片、显示文章等功能。

随意定制您的应用程序,无论您喜欢什么——我什至鼓励这样做。

您可以在此处访问此应用程序的存储库:

Building a Dynamic Blog Dashboard with Next.js 冈德拉克08 / 博客平台

使用 Next.js/typescript 制作的博客平台。

博客平台

  • 文本教程

成分

  • next-auth - Next.js 的autenticação 图书馆
  • github.com/markdown-it/markdown-it - markdown biblioteca。
  • github.com/sindresorhus/github-markdown-css- Para dar estilo ao nosso markdown 编辑器。
  • github.com/remarkjs/react-markdown - Biblioteca para renderizar markdown em nosso 组件react。
  • github.com/remarkjs/remark-react/tree/4722bdf - React 中 Markdown 转换插件。
  • codemirror.net - 网络编辑器组件。
  • react-icons - 反应图标库。

科莫美国

npm i
npm run start
登录后复制
登录后复制
登录后复制
登录后复制
登录后复制

服务器

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


在 GitHub 上查看


本教程还包括本指南中将使用的 Node.js 服务器的编写:

希望您喜欢。

编码愉快!

图书馆

以下是此项目中使用的库的摘要:

  • next-auth - Next.js 的身份验证库
  • github.com/markdown-it/markdown-it - Markdown 库。
  • github.com/sindresorhus/github-markdown-css - 用于设计我们的 Markdown 编辑器。
  • github.com/remarkjs/react-markdown - 用于在 React 组件中渲染 Markdown 的库。
  • github.com/remarkjs/remark-react/tree/4722bdf - 将 Markdown 转换为 React 的插件。
  • codemirror.net - Web 组件编辑器。
  • react-icons - React 的图标库。

创建 React 项目

我们将使用最新版本的 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

在项目根目录的 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
登录后复制
登录后复制
登录后复制

布局

现在让我们编写私有和公共的布局。

应用程序/布局.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
登录后复制
登录后复制
登录后复制

页面/layout.tsx

npm i react-icons @types/react-icons
登录后复制
登录后复制
登录后复制

API调用

我们的应用程序将多次调用我们的 API,您可以调整此应用程序以使用任何外部 API。在我们的示例中,我们使用本地应用程序。如果你还没有看过后端教程和服务器创建,请查看。

在 src/services/ 中,我们编写以下函数:

  1. authService.ts:负责在服务器上验证用户身份的函数。
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"],
  },
};
登录后复制
登录后复制
  1. getArticles.tsx:负责获取数据库中保存的所有文章的函数:
export { default } from "next-auth/middleware";
export const config = {
  matcher: ["/", "/newArticle/", "/article/", "/article/:path*"],
};
登录后复制
  1. postArticle.tsx:负责将文章数据提交到我们的服务器的函数。
.env.local
NEXTAUTH_SECRET = SubsTituaPorToken
登录后复制
  1. editArticle.tsx:负责修改数据库中特定文章的函数。
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 };
登录后复制
  1. deleteArticle.tsx:负责从数据库中删除特定文章的函数。
'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>
    )
};
登录后复制

成分

接下来,让我们编写整个应用程序中使用的每个组件。

组件/Navbar.tsx

一个带有两个导航链接的简单组件。

/*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;
}
登录后复制

组件/Loading.tsx

一个简单的加载组件,在等待 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>
  );
}
登录后复制

组件/分页.tsx

我们页面上使用的分页组件,在我们的私有路径中显示我们的所有文章。您可以在这里找到有关如何编写此组件的更详细的文章

npm i
npm run start
登录后复制
登录后复制
登录后复制
登录后复制
登录后复制

组件/ArticleCard.tsx

用于显示书面文章的卡片组件。

该组件还包含一个链接,该链接将指向文章显示页面和编辑先前撰写的文章的页面。

npx create-next-app myblog
登录后复制
登录后复制
登录后复制

组件/ArticleList.tsx

负责进行 API 调用并显示响应的组件。

在这里,我们将通过我们编写的函数使用两个 API 调用:

  1. getArticles.ts - 返回将在组件中显示的所有文章。
  2. removeArticle - 从我们的列表和服务器中删除特定的文章。

我们将使用之前编写的 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.

  • id 对应于我们导航路线的 slug。
  • params 是通过包含导航 slug 的应用程序树传递的属性。
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
登录后复制
登录后复制
登录后复制
登录后复制
登录后复制

新文章

这是我们应用程序中最重要的页面之一,因为它允许我们注册我们的文章。

此页面将使用户能够:

  1. 以 Markdown 格式写一篇文章。
  2. 为文章分配图像。
  3. 在将 Markdown 文本提交到服务器之前预览它。

页面使用了多个钩子

  1. useCallback - 用于记忆函数。
  2. useState - 允许您向我们的组件添加状态变量。
  3. useSession - 让我们检查用户是否经过身份验证并获取身份验证令牌。

为此,我们将使用两个组件:

  1. TextEditor.tsx:我们之前编写的文本编辑器。
  2. Preview.tsx:用于显示 Markdown 格式文件的组件。

在构建此页面时,我们将使用我们的 API:

  1. POST:使用我们的函数 postArticle,我们将把文章发送到服务器。

我们还将使用 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中文网其他相关文章!

来源:dev.to
本站声明
本文内容由网友自发贡献,版权归原作者所有,本站不承担相应法律责任。如您发现有涉嫌抄袭侵权的内容,请联系admin@php.cn
作者最新文章
热门教程
更多>
最新下载
更多>
网站特效
网站源码
网站素材
前端模板