こんにちは、お元気ですか?こちらは Vítor です。プログラミング スキルの向上に役立つ新しいプロジェクトを携えて戻ってきました。最後にチュートリアルを公開してからしばらく時間が経ちました。過去数か月間、私は休息を取り、他の活動に集中する時間を取りました。この期間中に、私は小さな Web プロジェクト、つまりこのチュートリアルの焦点となるブログを開発しました。
このガイドでは、Markdown をレンダリングできるブログ ページのフロントエンドを作成します。このアプリケーションには、パブリックおよびプライベート ルート、ユーザー認証、Markdown テキストの作成、写真の追加、記事の表示などの機能が含まれます。
お好みに合わせてアプリケーションを自由にカスタマイズしてください。私はそれをお勧めします。
このアプリケーションのリポジトリにはここからアクセスできます:
npm i npm run start
サーバーアプリケーションのサーバーを起動します
このチュートリアルには、このガイドで使用される 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 を使用すると、ユーザー名とパスワード、ドメイン、2 要素認証、ハードウェア デバイスなどの任意の資格情報を使用してログインを処理できます。
まず、プロジェクトのルートに .env.local ファイルを作成し、シークレットとして使用されるトークンを追加します。
npm i npm run start
次に、認証システムを作成しましょう。この NEXTAUTH_SECRET が src/app/auth/[...nextauth]/routes.ts ファイル内のシークレットに追加されます。
npx create-next-app myblog
プライベート ルートのページ間でユーザーのデータを共有する認証プロバイダー、つまりコンテキストを作成しましょう。後でこれを使用して、layout.tsx ファイルの 1 つをラップします。
次の内容を含むファイルを 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> ) };
次に、アプリケーション全体で使用される各コンポーネントを記述しましょう。
2 つのナビゲーション リンクを持つ単純なコンポーネント。
/*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 呼び出しを実行し、応答を表示するコンポーネント。
ここでは、作成した関数を通じて 2 つの 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
2 番目: 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/ フォルダー内で、ファイルが () 内で宣言されている場合、ルートが /.
に対応することを意味します。この場合、(ホーム) フォルダーはプライベート ルートのホームページを指します。これは、ユーザーがシステムに認証されたときに最初に表示されるページです。このページには、データベースの記事のリストが表示されます。
データは ArticlesList.tsx コンポーネントによって処理されます。このコードをまだ書いていない場合は、コンポーネントのセクションに戻って参照してください。
app/(pages)/(private)/(home)/page.tsx 内
npm i npm run start
これは、記事を登録できるため、アプリケーションの最も重要なページの 1 つです。
このページにより、ユーザーは次のことが可能になります:
このページでは複数の フック が使用されています:
このために、2 つのコンポーネントを使用します:
このページを構築する際、次の API を使用します:
また、next-auth ライブラリによって提供される useSession フックを使用して、サーバーに記事を登録するために使用されるユーザーの認証トークンを取得します。
これには 3 つの異なる API 呼び出しが含まれます。
app/pages/(private)/newArticle/page.tsx.
内
「クライアントを使用する」; import React, { ChangeEvent, useCallback, useState } from "react"; import { useSession } から "next-auth/react"; import { リダイレクト } から "next/navigation"; "@/services/postArticle" から postArtical をインポートします。 import { AiOutlineFolderOpen } from "react-icons/ai"; import { RiImageEditLine } から "react-icons/ri"; 「next/image」から画像をインポートします。 "@/components/textEditor" から TextEditor をインポートします。 "@/components/PreviewText" からプレビューをインポートします。 import { AiOutlineSend } から "react-icons/ai"; import { BsBodyText } から "react-icons/bs"; デフォルト関数をエクスポート NewArticle(params:any) { const { データ: セッション }: any = useSession({ 必須: true、 onUnauthenticated() { リダイレクト("/ログイン"); }、 }); const [imageUrl, setImageUrl] = useState<object>({}); const [previewImage, setPreviewImage] = useState<string>(""); const [previewText, setPreviewText] = useState<boolean>(false); const [title, setTitle] = useState<string>(""); const [doc, setDoc] = useState<string>("# Escreva o seu texto... n"); const handleDocChange = useCallback((newDoc: any) => { setDoc(newDoc); }、[]); (!session?.user) が null を返す場合。 const handleArticleSubmit = async (e:any) => { e.preventDefault(); const トークン: 文字列 = session.user.token; 試す { const res = await postArtical({ id: session.user.userId.toString()、 トークン: トークン、 画像 URL: 画像 URL、 タイトル: "タイトル"、 ドキュメント: ドキュメント、 }); 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(ファイル); setPreviewImage(url); setImageUrl(ファイル); } }; const handleTextPreview = (e: any) => { e.preventDefault(); setPreviewText(!previewText); }; 戻る ( <section className="w-full h-full min-h-screen相対 py-8"> {プレビューテキスト && ( <div className="absolute right-16 top-5 p-5 border-2 border-slate-500 bg-slate-100rounded-xl w-full max-w-[33em] z-30"> <プレビュー ドキュメント={ドキュメント} タイトル={タイトル} プレビュー画像={プレビュー画像} 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-200rounded-md bg-slate-50ドロップシャドウ-xl フレックス フレックスコル ギャップ-2 "> {" "} <div className="flex justify-between items-center"> <ボタン className="border-b-2rounded-md border-slate-500 p-2 flex items-center gap-2 hover:border-slate-400 hover:text-slate-800" onClick={handleTextPreview} > <BsBodyText /> プレビュー </ボタン>{" "} <ボタン className="グループ ボーダー ボーダー-b-2 ボーダー-スレート-500 ラウンド-MD p-2 フレックス アイテム-センター ギャップ-2 hover:border-slate-400 hover:text-slate-800 " onClick={handleArticleSubmit} > エンビア テキスト <AiOutlineSend className="w-5 h-5 group-hover:text-red-500" /> </ボタン> </div> <div className="header-wrapper flex flex-col gap-2 "> <div className="画像ボックス"> {previewImage.length === 0 && ( <div className="選択画像"> <AiOutlineFolderOpen className="w-7 h-7" /> ドラッグ アンド ドロップ画像 </ラベル> <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">「クライアントを使用する」; import React, { useState, useEffect, useCallback, useRef, ChangeEvent } from "react"; import { useSession } から "next-auth/react"; import { リダイレクト } から "next/navigation"; 'next/image' から画像をインポートします。 import { IArticle } から "@/interfaces/article.interface"; import { AiOutlineEdit } から "react-icons/ai"; import { BsBodyText } から "react-icons/bs"; import { AiOutlineFolderOpen } from "react-icons/ai"; import { RiImageEditLine } から "react-icons/ri"; "@/components/PreviewText" からプレビューをインポートします。 "@/components/textEditor" から TextEditor をインポートします。 import Loading from '@/components/Loading'; editArtical を "@/services/editArticle" からインポートします。 デフォルト関数をエクスポート EditArticle({ params }: { params: any }) { const { データ: セッション }: any = useSession({ 必須: true、 onUnauthenticated() { リダイレクト("/ログイン"); }、 }); 定数 ID: 数値 = params.id; const [article, setArticle] = useState<iarticle null>(null); const [imageUrl, setImageUrl] = useState<object>({}); const [previewImage, setPreviewImage] = useState<string>(""); const [previewText, setPreviewText] = useState<boolean>(false) const [title, 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) => { 試す { const 応答 = fetch を待ちます( `http://localhost:8080/articles/getById/${id}`、 ); const jsonData = 応答を待ちます.json(); setArticle(jsonData); } キャッチ (エラー) { console.log("何か問題が発生しました:", err); } }; useEffect(() => { if (記事 !== null || 記事 !== 未定義) { fetchArticle(id); } }, [id]); useEffect(()=>{ if(記事 != null && 記事.コンテンツ){ setDoc(article.content) } if(記事 !=null && 記事.画像){ setPreviewImage(`http://localhost:8080/`article.image) } }、[記事]) const handleArticleSubmit = async (e:any) => { e.preventDefault(); const トークン: 文字列 = session.user.token; 試す{ const res = await editArtical({ ID: ID、 トークン: トークン、 画像URL:画像URL、 タイトル: タイトル、 ドキュメント: ドキュメント、 }); 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(ファイル); setPreviewImage(url); setImageUrl(ファイル); } }; const handleTextPreview = (e: any) => { e.preventDefault(); setPreviewText(!previewText); console.log('プレビューからこんにちは!') }; if(!article) return <Loading/> if(記事?.コンテンツ) 戻る ( <section className='w-full h-full min-h-screen相対 py-8'> {プレビューテキスト && ( <div className="absolute right-16 top-5 p-5 border-2 border-slate-500 bg-slate-100rounded-xl w-full max-w-[33em] z-30"> <プレビュー ドキュメント={ドキュメント} タイトル={タイトル} プレビュー画像={プレビュー画像} 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-200rounded-md bg-whitedrop-シャドウ-MD フレックス フレックス-コル ギャップ-2'> <form className='relative mx-auto max-w-[700px] h-full min-h-[90%] w-full p-2 border-2 border-slate-200rounded-md bg-slate-50ドロップシャドウ-MD フレックス フレックスコル ギャップ-2 '> {" "} <div className='flex justify-between items-center'> <ボタン className='border-b-2rounded-md border-slate-500 p-2 flex items-center gap-2 hover:border-slate-400 hover:text-slate-800' onClick={handleTextPreview} > <BsBodyText /> プレビュー </ボタン>{" "} <ボタン className='グループ ボーダー ボーダー-b-2 ボーダー-スレート-500 ラウンド-MD p-2 フレックス アイテム-センター ギャップ-2 hover:border-slate-400 hover:text-slate-800 ' onClick={handleArticleSubmit} > アーティゴを編集する <AiOutlineEdit className='w-5 h-5 group-hover:text-red-500' /> </ボタン> </div> <div className='header-wrapper flex flex-col gap-2 '> <div className='画像ボックス'> {previewImage.length === 0 && ( <div className='select-image'> <AiOutlineFolderOpen className='w-7 h-7' />; ドラッグ アンド ドロップ画像 </ラベル> <h2> 結論 </h2> <p>まず、このチュートリアルをお読みいただくために時間を割いていただきありがとうございます。また、このチュートリアルが完了したことをお祝いしたいと思います。ご参考になり、ステップごとの手順が分かりやすかったら幸いです。</p> <p>次に、構築したものについていくつかの点を強調したいと思います。これはブログ システムの基礎であり、すべての記事を表示する公開ページ、ユーザー登録ページ、さらにはカスタム 404 エラー ページなど、追加すべき点はまだたくさんあります。チュートリアル中にこれらのページについて疑問に思って見逃した場合は、これが意図的なものであることを知ってください。このチュートリアルでは、これらの新しいページを自分で作成し、他のページを追加し、新機能を実装するための十分な経験を提供しました。</p> <p>次回まで、本当にありがとうございました。 o/</p> </htmlinputelement></iarticle>
以上がNext.js を使用した動的なブログ ダッシュボードの構築の詳細内容です。詳細については、PHP 中国語 Web サイトの他の関連記事を参照してください。