안녕하세요? 프로그래밍 기술을 향상시키는 데 도움이 되는 새로운 프로젝트로 돌아온 Vítor입니다. 마지막으로 튜토리얼을 게시한 지 꽤 시간이 지났습니다. 지난 몇 달 동안 저는 휴식을 취하고 다른 활동에 집중하는 시간을 가졌습니다. 이 기간 동안 저는 이 튜토리얼의 초점이 된 블로그라는 작은 웹 프로젝트를 개발했습니다.
이 가이드에서는 마크다운을 렌더링할 수 있는 블로그 페이지의 프런트엔드를 만들어 보겠습니다. 애플리케이션에는 공개 및 비공개 경로, 사용자 인증, 마크다운 텍스트 작성, 사진 추가, 기사 표시 등의 기능이 포함됩니다.
원하는 대로 애플리케이션을 자유롭게 맞춤설정하세요. 권장합니다.
여기에서 이 애플리케이션의 저장소에 액세스할 수 있습니다.
npm i npm run start
EM 서버에 대한 você pode encontrar o servidor dessa aplicação em server
이 튜토리얼에는 이 가이드에서 사용될 Node.js 서버 작성도 포함되어 있습니다.
즐기시기 바랍니다.
즐거운 코딩하세요!
다음은 이 프로젝트에 사용된 라이브러리에 대한 요약입니다.
이 튜토리얼을 작성할 당시 버전 13.4인 Next.js 프레임워크의 최신 버전을 사용하겠습니다.
다음 명령을 실행하여 프로젝트를 생성하세요.
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
비공개 경로의 페이지 전체에서 사용자 데이터를 공유할 인증 공급자인 컨텍스트를 만들어 보겠습니다. 나중에 이를 레이아웃.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] 폴더를 추가하여 구조에 경로를 생성해야 합니다. 결과적으로 페이지/(공개)/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/ 폴더 내에서 () 안에 파일이 선언되어 있다는 것은 해당 경로가 /에 해당한다는 의미입니다.
저희 경우 (홈) 폴더는 저희 프라이빗 루트의 홈페이지를 의미합니다. 사용자가 시스템에 인증할 때 보게 되는 첫 번째 페이지입니다. 이 페이지에는 당사 데이터베이스의 기사 목록이 표시됩니다.
데이터는 ArticlesList.tsx 구성 요소에 의해 처리됩니다. 아직 이 코드를 작성하지 않았다면 구성 요소 섹션을 다시 참조하세요.
앱 내/(페이지)/(비공개)/(홈)/page.tsx.
npm i npm run start
이 페이지는 기사를 등록할 수 있는 신청서의 가장 중요한 페이지 중 하나입니다.
이 페이지를 통해 사용자는 다음을 수행할 수 있습니다.
이 페이지는 여러 후크를 사용합니다.
이를 위해 두 가지 구성 요소를 사용합니다.
이 페이지를 구성하는 동안 다음 API를 사용합니다.
또한 next-auth 라이브러리에서 제공하는 useSession 후크를 사용하여 서버에 기사를 등록하는 데 사용될 사용자 인증 토큰을 얻습니다.
여기에는 세 가지 개별 API 호출이 포함됩니다.
앱/페이지/(비공개)/newArticle/page.tsx.
"클라이언트 사용"; import React, { ChangeEvent, useCallback, useState } from "react"; "next-auth/react"에서 import { useSession }; "다음/탐색"에서 가져오기 { 리디렉션 }; "@/services/postArticle"에서 postArtical을 가져옵니다. "react-icons/ai"에서 { AiOutlineFolderOpen }을 가져옵니다. "react-icons/ri"에서 { RiImageEditLine }을 가져옵니다. "다음/이미지"에서 이미지를 가져옵니다. "@/comComponents/textEditor"에서 TextEditor를 가져옵니다. "@/comComponents/PreviewText"에서 미리보기를 가져옵니다. "react-icons/ai"에서 { AiOutlineSend }를 가져옵니다. "react-icons/bs"에서 { BsBodyText }를 가져옵니다. 기본 함수 내보내기 NewArticle(params:any) { const { 데이터: 세션 }: any = useSession({ 필수: 사실, 인증되지 않은() { 리다이렉트("/로그인"); }, }); const [imageUrl, setImageUrl] = useState<object>({}); const [previewImage, setPreviewImage] = useState<string>(""); const [previewText, setPreviewText] = useState(false); const [title, setTitle] = useState<string>(""); const [doc, setDoc] = useState<string>("# Escreva o seu texto...n"); const handlerDocChange = useCallback((newDoc: any) => { setDoc(newDoc); }, []); if (!session?.user)는 null을 반환합니다. const handlerArticleSubmit = async (e:any) => { e.preventDefault(); const 토큰: 문자열 = session.user.token; 노력하다 { const res = postArtical({ ID: session.user.userId.toString(), 토큰: 토큰, imageUrl: imageUrl, 제목: "제목," 문서: 문서, }); console.log('re--->', res); 리디렉션('/성공'); } 잡기(오류) { console.error('기사 제출 오류:', error); // 필요한 경우 오류를 처리합니다. 오류 발생; } }; const handlerImageChange = (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 handlerTextPreview = (e: 임의) => { 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-100 rounded-xl w-full max-w-[33em] z-30"> <미리보기 문서={문서} 제목={제목} PreviewImage={previewImage} 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 rounded-md bg-slate-50 drop-shadow-xl 플렉스 flex-col gap-2 "> {" "} <div className="flex justify-between items-center"> <버튼 className="border-b-2 rounded-md border-slate-500 p-2 플렉스 항목-센터 gap-2 hover:border-slate-400 hover:text-slate-800" onClick={handleTextPreview} > <BsBodyText /> 시사 </버튼>{" "} <버튼 className="그룹 테두리 border-b-2 border-slate-500 rounded-md p-2 flex items-center gap-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="image-box"> {previewImage.length === 0 && ( <div className="select-image"> <라벨 htmlFor="이미지" className="p-4 border-dashed border-4 border-slate-400 커서-포인터 플렉스 플렉스-col 항목-센터 정당화-센터" > <AiOutlineFolderOpen className="w-7 h-7" /> 드래그 앤 드롭 이미지 </라벨> <입력 > <h4> 기사 편집 </h4> <p><em>New Article</em>(newArticle)과 유사하지만 약간의 차이점이 있는 페이지입니다.</p> <p>먼저 ID를 탐색 매개변수로 받는 동적 경로를 정의합니다. 이는 기사 읽기 페이지에서 수행된 작업과 매우 유사합니다. <br> app/(페이지)/(private)/editArticle/[id]/page.tsx<br> </p><pre class="brush:php;toolbar:false">"클라이언트 사용"; "반응"에서 React, { useState, useEffect, useCallback, useRef, ChangeEvent }를 가져옵니다. "next-auth/react"에서 import { useSession }; "다음/탐색"에서 가져오기 { 리디렉션 }; '다음/이미지'에서 이미지를 가져옵니다. "@/interfaces/article.interface"에서 { IArticle }을 가져옵니다. "react-icons/ai"에서 { AiOutlineEdit }를 가져옵니다. "react-icons/bs"에서 { BsBodyText }를 가져옵니다. "react-icons/ai"에서 { AiOutlineFolderOpen }을 가져옵니다. "react-icons/ri"에서 { RiImageEditLine }을 가져옵니다. "@/comComponents/PreviewText"에서 미리보기를 가져옵니다. "@/comComponents/textEditor"에서 TextEditor를 가져옵니다. '@/comComponents/Loading'에서 로드 가져오기; "@/services/editArticle"에서 editArtical을 가져옵니다. 기본 함수 내보내기 EditArticle({ params }: { params: any }) { const { 데이터: 세션 }: any = useSession({ 필수: 사실, 인증되지 않은() { 리다이렉트("/로그인"); }, }); const id: 숫자 = params.id; const [기사, setArticle] = useState<IArticle | 널>(널); const [imageUrl, setImageUrl] = useState<object>({}); const [previewImage, setPreviewImage] = useState<string>(""); const [previewText, setPreviewText] = useState(false) const [title, setTitle] = useState<string>(""); const [doc, setDoc] = useState<string>(''); const handlerDocChange = useCallback((newDoc: any) => { setDoc(newDoc); }, []); const inputRef= useRef<HTMLInputElement>(null); const fetchArticle = async (id: 숫자) => { 노력하다 { const 응답 = 가져오기를 기다립니다( `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/` 기사.이미지) } },[기사]) const handlerArticleSubmit = async (e:any) => { e.preventDefault(); const 토큰: 문자열 = session.user.token; 노력하다{ const res = editArtical({ 아이디: 아이디, 토큰: 토큰, imageUrl:imageUrl, 제목: 제목, 문서: 문서, }); console.log('re--->',res) 해상도를 반환; } 잡기(오류){ console.log("오류:", 오류) } }; const handlerImageClick = ()=>{ console.log('hiii') if(inputRef.current){ inputRef.current.click(); } }const handlerImageChange = (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 handlerTextPreview = (e: 임의) => { 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-100 rounded-xl w-full max-w-[33em] z-30"> <미리보기 문서={문서} 제목={제목} PreviewImage={previewImage} 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 rounded-md bg-white drop- 섀도우-MD 플렉스 플렉스-콜 갭-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 gap-2 '> {" "} <div className='flex justify-between items-center'> <버튼 className='국경-b-2 반올림-md 국경-슬레이트-500 p-2 플렉스 항목-중심 간격-2 hover:border-slate-400 hover:text-slate-800' onClick={handleTextPreview} > <BsBodyText /> 시사 </버튼>{" "} <버튼 className='그룹 테두리 border-b-2 border-slate-500 rounded-md p-2 flex items-center gap-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='image-box'> {previewImage.length === 0 && ( <div className='select-image'> <라벨 htmlFor='이미지' className='p-4 border-dashed border-4 border-slate-400 커서-포인터 flex flex-col 항목-센터 정당화-센터' > <AiOutlineFolderOpen className='w-7 h-7' /> 드래그 앤 드롭 이미지 </라벨> <입력 > <h2> 결론 </h2> <p>먼저 시간을 내어 이 튜토리얼을 읽어주셔서 감사드리며, 튜토리얼을 완료하신 것도 축하드립니다. 도움이 되셨기를 바라며, 단계별 지침을 따라하기 쉬웠기를 바랍니다.</p> <p>두 번째로, 방금 구축한 기능에 대해 몇 가지 사항을 강조하고 싶습니다. 이는 블로그 시스템의 기초이며, 모든 글을 표시하는 공개 페이지, 사용자 등록 페이지, 사용자 정의 404 오류 페이지 등 추가할 내용이 여전히 많습니다. 튜토리얼을 진행하는 동안 이러한 페이지에 대해 궁금해서 놓친 경우 이는 의도된 것임을 알아두세요. 이 튜토리얼에서는 이러한 새 페이지를 직접 만들고, 다른 페이지를 추가하고, 새로운 기능을 구현하는 데 충분한 경험을 제공했습니다.</p> <p>정말 감사합니다. 다음 시간까지. ㅇ/</p>
위 내용은 Next.js로 동적 블로그 대시보드 구축의 상세 내용입니다. 자세한 내용은 PHP 중국어 웹사이트의 기타 관련 기사를 참조하세요!