NEXT.JS/Cock! 칵테일 프로젝트

[NEXT] 7. 게시판 만들기, 기본 CRUD

예글 2024. 3. 16. 22:27

드디어 마지막 관문인 게시판 만들기!

전 회사에서도 CRUD 정도는 해봤지만,, 백엔드 분이 만들어 주신 api를 받아서 프론트 쪽 작업만 했었기에,, 항상 api 만드는 과정이 궁금했었다 😳 다음 개인프로젝트에서 무조건 게시판을 만들어봐야겠다라고 결심한지 어언 몇 개월,, 드디어 실행에 옮겼다!

 

1️⃣ 글 작성페이지 만들기 (CREATE)

우선 퍼블리싱은 다 해놓은 상태니,, 데이터만 붙이이면 다 되니까 빨리 끝나겠지 ㅎㅎ 라고 생각했다면 경기도 오산 😧

 

1.  schema.prisma 에서 필드 정의하기

model Post {
  id        String     @default(cuid()) @id
  title     String
  content   String?
  published Boolean  @default(false)
  author    User?    @relation(fields: [authorId], references: [id])
  authorId  Int
  createdAt DateTime @default(now())
  updatedAt DateTime?
}

 

2. 게시글 작성 페이지

"use client";
import { useSession } from "next-auth/react";
import { useRouter } from "next/navigation";
import { SyntheticEvent, useEffect, useState } from "react";

const PostForm = () => {
  const [title, setTitle] = useState("");
  const [content, setContent] = useState("");
  const [authorId, setAuthorId] = useState<Number | null>(null);

  const { data: session } = useSession();
  const author = session?.user?.name;
  const router = useRouter();

  useEffect(() => {
    // 세션 데이터가 로드되면 사용자 식별자를 설정
    if (session?.user?.id) {
      setAuthorId(session.user.id);
    }
  }, [session]);

  const handleTitleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
    setTitle(e.target.value);
  };

  const handleContentChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
    setContent(e.target.value);
  };

  const handleSubmit = async (e: SyntheticEvent) => {
    e.preventDefault();

    // authorId가 null인 경우 처리
    if (authorId === null) {
      console.error("사용자 식별자가 없습니다. 사용자를 다시 로그인하세요.");
      return;
    }

    try {
      await fetch("/api/addpost", {
        method: "POST",
        headers: {
          "Content-Type": "application/json",
        },
        body: JSON.stringify({ title, content, authorId }),
      });
      router.refresh();
    } catch (error) {
      console.log(error);
    }

    setTitle("");
    setContent("");
  };

  return (
    <>
      {/* <h1>{initialData.title ? "글 수정하기" : "글 작성하기"}</h1> */}

      <form onSubmit={handleSubmit}>
        <div>
          <div>
            <label htmlFor="title">글 제목</label>
            <input
              type="text"
              id="title"
              name="title"
              value={title}
              onChange={handleTitleChange}
              required
            />
          </div>
        </div>

        <div>
          <label htmlFor="content">내용</label>
          <textarea
            name="content"
            id="content"
            value={content}
            onChange={handleContentChange}
            required
          />
        </div>

        <div>
          <button type="submit">저장하기</button>
          <button>취소하기</button>
        </div>
      </form>
    </>
  );
};

export default PostForm;

 

처음에는 작성자 이름을 보내려고 했으나,, db에서 author가 user와 관련되어 있어서 type 지정할 때 계속 오류가 나는 것이었다,,

여기서 한 시간은 헤맨 듯 ㅠㅠㅠ

그래서 그냥 authorId를 받아서 서버로 보내주기,, 생각해보니까 이름은 동명이인이 있을 수 있으니 id를 받아서 보내는 게 더 좋을 것 같다는 생각이 들었다!

 

3. addPost api 만들기

import prisma from "@/app/lib/prisma";
import { NextResponse } from "next/server";

type PostCreateInput = {
  title: string;
  content: string;
  published: boolean;
  authorId: number;
};

export async function POST(request: Request) {
  const res: PostCreateInput = await request.json();

  const { title, content, authorId } = res;

  const result = await prisma.post.create({
    data: {
      title,
      content,
      published: true,
      authorId,
      updatedAt: new Date(),
    },
  });

  return NextResponse.json({ result });
}

 

여기에서도 계속 오류가 났었는데... 저 updatedAt 때문이었다

DB에서 updatedAt이 필수로 지정해져 있어서 optional 하게 바꾸었는데도,,, 그래서 저렇게 new Date()로 지정해놓으니까 드디어 해결!

이게 맞는 방법인지 모르겠지만,, 차근차근 고쳐나가도록 해야겠다 🥹

 

4. postForm 컴포넌트에 붙이기

"use client";
import { useSession } from "next-auth/react";
import { useRouter } from "next/navigation";
import { SyntheticEvent, useEffect, useState } from "react";
import * as styles from "./index.css";

const PostForm = () => {
  const [title, setTitle] = useState("");
  const [content, setContent] = useState("");
  const [authorId, setAuthorId] = useState<Number | null>(null);

  // 로그인한 유저 데이터
  const { data: session } = useSession();
  const router = useRouter();

  useEffect(() => {
    // 세션 데이터가 로드되면 사용자 식별자를 설정
    if (session?.user?.id) {
      setAuthorId(session.user.id);
    }
  }, [session]);

  const handleTitleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
    setTitle(e.target.value);
  };

  const handleContentChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
    setContent(e.target.value);
  };

  const handleSubmit = async (e: SyntheticEvent) => {
    e.preventDefault();

    // authorId가 null인 경우 처리
    if (authorId === null) {
      console.error("사용자 식별자가 없습니다. 사용자를 다시 로그인하세요.");
      return;
    }

    try {
      await fetch("/api/addpost", {
        method: "POST",
        headers: {
          "Content-Type": "application/json",
        },
        body: JSON.stringify({ title, content, authorId }),
      });
      alert("저장하시겠습니까?");
      router.push("/board");
      router.refresh();
    } catch (error) {
      console.log(error);
    }

    setTitle("");
    setContent("");
  };

  return (
    <div className={styles.formBox}>
      {/* <h1>{initialData.title ? "글 수정하기" : "글 작성하기"}</h1> */}

      <form onSubmit={handleSubmit} className={styles.form}>
        <div className={styles.inputBox}>
          <label htmlFor="title" className={styles.label}>
            제목
          </label>
          <input
            type="text"
            id="title"
            name="title"
            value={title}
            onChange={handleTitleChange}
            required
            className={styles.input}
          />
        </div>

        <div className={styles.textAreaBox}>
          <label htmlFor="content" className={styles.label}>
            내용
          </label>
          <textarea
            name="content"
            id="content"
            value={content}
            onChange={handleContentChange}
            required
            className={styles.textArea}
          />
        </div>

        <div className={styles.buttonBox}>
          <button type="submit" className={styles.saveBtn}>
            저장하기
          </button>
        </div>

        <div className={styles.buttonBox}>
          <p
            onClick={() => {
              alert(
                "작성 중인 내용은 저장되지 않습니다. 그래도 취소하시겠습니까?"
              );
              router.push("/board");
            }}
            className={styles.cancelTxt}
          >
            취소하기
          </p>
        </div>
      </form>
    </div>
  );
};

export default PostForm;

 

 

5. DB에서 확인

 

다행히 잘 들어오는 것이 확인된다,,

2️⃣ 게시글 데이터 불러오기 (READE)

1. 전체 게시글 리스트

// 전체 게시글 가져오는 함수
async function getPosts() {
  const posts = await prisma.post.findMany({
    where: { published: true },
    include: {
      author: {
        select: { name: true }, // 작성자의 이름만을 선택
      },
    },
  });

  // 각 게시물의 작성자 이름만을 추출하여 반환
  return posts.map((post) => ({
    ...post,
    authorName: post.author?.name || "Unknown", // 작성자 이름 또는 'Unknown'으로 설정
  }));
}

export default async function BoardListPage() {
  const posts = await getPosts();

  return (
    <div className={styles.root}>
      {/* <div className={styles.hotPost}>
        <h2>인기 게시글</h2>
        <div className={styles.hotPostItemBox}>
          <BoardItem id={dummy.id} />
        </div>
      </div> */}

      <div className={styles.newPost}>
        <div className={styles.topBox}>
          <h2>최근 게시글</h2>
          <Link href="/board/write" className={styles.goWrite}>
            게시글 작성 &gt;
          </Link>
        </div>

        <div className={styles.newPostTable1}>
          <span className={`${styles.th} ${styles.th1}`}>No</span>
          <span className={`${styles.th} ${styles.th2}`}>제목</span>
          <span className={`${styles.th} ${styles.th3}`}>작성자</span>
          <span className={`${styles.th} ${styles.th4}`}>날짜</span>
          <span className={`${styles.th} ${styles.th5}`}>추천수</span>
        </div>

        <div className={styles.newPostTable2}>
          {posts.map((post, index) => {
            return (
              <BoardNewItem
                num={index + 1}
                key={post.id}
                id={post.id}
                title={post.title}
                content={post.content}
                authorName={post.authorName}
                createdAt={post.createdAt}
              />
            );
          })}
        </div>
      </div>
    </div>
  );
}

BoardNewItem 컴포넌트를 만들어서 데이터를 보내도록 하였다.

처음에는 grid로 스타일링 하여 한 줄에 4개씩 보이게 만들었는데 아무래도 게시판은 기본 게시판 스타일로 보여주는 게 좋을 것 같아서 

 

2. 개별 게시글 페이지

 

- 개별 게시글 페이지 데이터 가져오는 api 작성 (api/post/[id]/route.ts)

import prisma from "@/app/lib/prisma";
import { NextResponse } from "next/server";

export async function GET(request: Request, { params }: any) {
  const id = params.id;

  const post = await prisma.post.findUnique({
    where: { id },
    include: {
      author: true,
    },
  });

  const authorName = post?.author?.name || "Unknown";

  // 필요한 데이터만 추출하여 반환
  const postData = {
    id: post?.id,
    title: post?.title,
    content: post?.content,
    published: post?.published,
    authorId: post?.authorId,
    createdAt: post?.createdAt,
    updatedAt: post?.updatedAt,
    authorName: authorName, // 작성자 이름 추가
  };

  return NextResponse.json(postData);
}

 

- 상세페이지 features에 적용

"use client";

const BoardDetailPage = ({ postId }: any) => {
  // 로그인한 유저 정보
  const { data: session } = useSession();
  const userId = session?.user?.id;

  // 좋아요
  const [like, setLike] = useState(false);

  const OnHeartClick = () => {
    setLike(!like);
  };

  // 게시글
  const [postState, setPostState] = useState<{
    title: string;
    authorName: string;
    createdAt: string;
    updatedAt: string | null;
    content: string | null;
    authorId: number | null;
  }>({
    title: "",
    authorName: "",
    createdAt: "",
    updatedAt: null,
    content: null,
    authorId: null,
  });

  // 게시글 데이터 가져오기
  useEffect(() => {
    async function fetchPost() {
      try {
        const response = await fetch(`/api/post/${postId}`, {
          method: "GET",
        });
        const postData = await response.json();
        setPostState(postData);
      } catch (error) {
        console.error("Error fetching post:", error);
      }
    }

    fetchPost();
  }, [postId]);

  const newFormat = dayjs(postState?.createdAt).format("YYYY-MM-DD H:mm:ss");

  return (
    <div className={styles.root}>
      <div className={styles.titleBox}>
        <div className={styles.titleTop}>
          <p className={styles.title}>{postState?.title}</p>

          {/* 로그인한 유저와 글쓴 유저가 같을 때만 나타남 */}
          {userId === postState?.authorId && (
            <div className={styles.subTxt}>
              <UpdatePostButton postId={postId} />
              <span className={styles.bar}>|</span>
              <DeletePostButton postId={postId} />
            </div>
          )}
        </div>

        <div className={styles.info}>
          <p>작성자: {postState?.authorName}</p>
          <span>{newFormat}</span>
        </div>
      </div>

      <div className={styles.top}>
        {/* <span className={styles.mainImgBox}>
          <Image src={example1} alt="예시사진" className={styles.mainImg} />
        </span> */}
        <span className={styles.heartBox}>
          <Image
            src={like ? heart_fill : heart}
            alt="빈 하트"
            className={styles.heartImg}
            onClick={OnHeartClick}
          />
        </span>
      </div>

      <div className={styles.bottom}>
        <div className={styles.nameWrap}>{postState?.content}</div>
      </div>
    </div>
  );
};

export default BoardDetailPage;
  • fetch 함수를 통해서 만들어놓은 api를 불러오고
  • 로그인 한 유저의 아이디와 작성자 아이디가 일치하면 수정, 삭제 버튼이 보이도록 하였다.
  • 하트를 누르면 검은색 하트로 바뀌면서 그 정보가 db에 저장되도록 할 예정

  • 로그인한 유저와 작성자가 같지 않을 때

  • 로그인한 유저와 작성자가 같을 때

3️⃣ 게시글 수정하기 (UPDATE)

1. update api 작성하기

export async function POST(request: Request, { params }: any) {
  const id = params.id;
  const { title, content, authorId } = await request.json(); // JSON 형식으로 파싱

  // 글 수정 로직 구현
  const updatedPost = await prisma.post.update({
    where: { id },
    data: {
      title,
      content,
      authorId,
    },
  });

  return NextResponse.json(updatedPost);

 

2. postForm 컴포넌트 수정

"use client";

const PostForm = ({ postId }: any) => {
 ...

  useEffect(() => {
    // postId가 존재하면 해당 글 데이터를 불러옴
    if (postId) {
      // postId를 사용하여 글 데이터를 불러오는 API 호출
      fetch(`/api/post/${postId}`)
        .then((response) => response.json())
        .then((postData) => {
          setTitle(postData.title);
          setContent(postData.content);
          // 작성자 식별자를 설정
          setAuthorId(postData.authorId);
        })
        .catch((error) => console.error("Error fetching post:", error));
    }
  }, [postId]);

  // 새 글 작성 및 수정 로직
  const handleSubmit = async (e: SyntheticEvent) => {
    e.preventDefault();

    // authorId가 null인 경우 처리
    if (authorId === null) {
      console.error("사용자 식별자가 없습니다. 사용자를 다시 로그인하세요.");
      return;
    }

    try {
      // postId가 있으면 글 수정, 없으면 새 글 작성
      const url = postId ? `/api/post/${postId}` : "/api/addpost";
      await fetch(url, {
        method: "POST",
        headers: {
          "Content-Type": "application/json",
        },
        body: JSON.stringify({ title, content, authorId }),
      });
      alert("저장하시겠습니까?");
      router.push("/board");
      router.refresh();
    } catch (error) {
      console.log(error);
    }

    setTitle("");
    setContent("");
  };

  return (
    <div className={styles.formBox}>
      <h1 className={styles.formTitle}>
        {title ? "글 수정하기" : "글 작성하기"}
      </h1>

      <form onSubmit={handleSubmit} className={styles.form}>
        <div className={styles.inputBox}>
          <label htmlFor="title" className={styles.label}>
            제목
          </label>
          <input
            type="text"
            id="title"
            name="title"
            value={title}
            onChange={handleTitleChange}
            required
            className={styles.input}
          />
        </div>

        <div className={styles.textAreaBox}>
          <label htmlFor="content" className={styles.label}>
            내용
          </label>
          <textarea
            name="content"
            id="content"
            value={content}
            onChange={handleContentChange}
            required
            className={styles.textArea}
          />
        </div>

        <div className={styles.buttonBox}>
          <button type="submit" className={styles.saveBtn}>
            저장하기
          </button>
        </div>

        <div className={styles.buttonBox}>
          <p
            onClick={() => {
              alert(
                "작성 중인 내용은 저장되지 않습니다. 그래도 취소하시겠습니까?"
              );
              router.push("/board");
            }}
            className={styles.cancelTxt}
          >
            취소하기
          </p>
        </div>
      </form>
    </div>
  );
};

export default PostForm;

 

수정 잘 되는 것 확인!

4️⃣ 게시글 삭제하기 (DELETE)

1. delete api 작성하기

import prisma from "@/app/lib/prisma";
import { NextResponse } from "next/server";

export async function DELETE(request: Request, { params }: any) {
  const id = params.id;

  const post = await prisma.post.delete({
    where: { id },
  });

  return NextResponse.json(post);
}

 

2. 삭제 버튼 컴포넌트 작성

"use client";

import { useRouter } from "next/navigation";
import * as styles from "./index.css";

const DeletePostButton = ({ postId }: any) => {
  const router = useRouter();

  async function handleClick() {
    try {
      await fetch(`/api/post/${postId}`, {
        method: "DELETE",
      });

      router.refresh;
    } catch (e) {
      console.error(e);
    }
  }

  return (
    <span onClick={handleClick} className={styles.deleteBtn}>
      삭제
    </span>
  );
};

export default DeletePostButton;

 

 

  • asdfasdf 게시글이 삭제된 것을 볼 수 있다

 

이렇게 기본적인 CRUD도 끝!

이렇게 api까지 만들어 db 연결하고,, 처음이라 많이 헤맸지만 (typescript 오류까지 겹쳐서 더더,,) 풀로 다 만들어보니 전체적인 과정도 알 수 있어서 좋은 경험이었다!