0429 CLANBE 개발일지

dowon kim·2024년 4월 29일
0

CLANBE

목록 보기
10/11

오늘은

공지사항 게시판 기능구현 / 공지사항 기능개편

스케쥴 페이지 구현 및 CRUD api와 서버액션

모바일 페이지 유저UI 구현

개발을 했다.


공지사항 게시판 기능구현 / 공지사항 기능개편

공지사항 게시판에서는 모든 게시판의 공지사항을 모아서 보여주는 방식으로 리팩토링 및 구현을 했고,

각 게시판에서 렌더링하는 컴포넌트의 렌더링 조건을 변경하여

각 카테고리 게시판에서는 공지사항 게시판의 모든 공지사항 글과 해당 카테고리에 해당하는 공지사항을 모아서 헤더에 보여주도록 변경했다.

구별을 위해 각 게시글 및 공지사항에 어떤 카테고리인지 렌더링 하는
태그를 붙여줬다.

라우팅페이지

import BoardLayout from "@/components/BoardLayout";
import { announce, board } from "../../../../public/data";
import { getAllPosts } from "@/service/posts";
import { revalidateTag } from "next/cache";

export default async function AllPostPage() {
  const category = "allposts"
  // 응답을 JSON으로 변환
  const response = await fetch(`${process.env.NEXT_PUBLIC_URL}/api/posts`, {
    method: "POST",
    headers: {
      "Content-Type": "application/json",
    },
    body: JSON.stringify({ category }),
    next: { tags: ["post"] },
    cache: "no-store",
  });

  const posts = await response.json();

  return (
    <BoardLayout
      boardTitle={"전체 게시글"}
      announce={posts.data}
      posts={posts.data}
      category={category}
    />
  );
}

공지사항 필터링 알고리즘

 <Card className="w-full max-w-full sm:max-w-[1000px] lg:max-w-[1200px] xl:max-w-[1400px] py-4 mx-auto">
        {[...announce]
          .filter((a) => a.noticed && a.category === "notice")
          .reverse()
          .map((announce) => (
            <NoticeCardHeader
              key={announce._id}
              announce={announce}
              date={getFormattedDate(announce.createdAt)}
            />
          ))}
        {[...announce]
          .filter(
            (a) =>
              a.noticed && a.category === category && a.category !== "notice"
          )
          .reverse()
          .map((announce) => (
            <NoticeCardHeader
              key={announce._id}
              announce={announce}
              date={getFormattedDate(announce.createdAt)}
            />
          ))}
        <Divider />
        {/* 현재 페이지의 게시물 렌더링 */}
        {currentPosts
          .filter(
            (a) =>
              (a.category === category || category === "allposts") &&
              a.category !== "notice"
          )
          .map((post, index) => (
            <PostCardComponent
              key={index}
              title={post.title}
              author={post.author}
              views={post.view}
              date={getFormattedDate(post.createdAt)}
              id={post._id}
              category={post.category}
            />
          ))}
      </Card>

실제 배포페이지 레이아웃


스케쥴 페이지 구현 및 CRUD api와 서버액션

Fullcalender 라이브러리를 활용하여 클랜일정을 확인할 수 있는 페이지를 구현하고 로그인 유저의 등급을 체크하여 드랍다운 액션 및 일정에 대한 수정기능 활성화 여부를 결정했다.

그 이후 해당 컴포넌트에 맞는 api를 작성하여 클랜 스케쥴 기능을 구현할 수 있었다.

crud 및 드랍다운에 필요한 메서드를 구성하며 필요한 타입을
라이브러리 자체에서 지원해주는 부분에서 편의성을 느꼈고,

달력에 대한 클릭 이벤트에서 매개변수를 뽑아내 로깅하며
내부 구조에 대해 파악하며 원하는대로 디테일한 동작을 구현하면서
색다른 경험을 할 수 있었다.

라우팅 페이지 및 서버액션


"use server";

import { revalidateTag } from "next/cache";

export async function getEvent() {
  const response = await fetch(`${process.env.NEXT_PUBLIC_URL}/api/event`, {
    method: "POST",
    headers: {
      "Content-Type": "application/json",
    },
    next: { tags: ["schedule"] },
    cache: "no-store",
  });

  // 응답을 JSON으로 변환
  const schedule = await response.json();

  return schedule;
}


import ScheduleComponent from "@/components/ScheduleComponent/ScheduleComponent";
import { getEvent } from "./action";

export default async function SchedulePage() {
  const schedule = await getEvent();
  return <ScheduleComponent events={schedule.events} />;
}

컴포넌트 코드

"use client";

import FullCalendar from "@fullcalendar/react";
import dayGridPlugin from "@fullcalendar/daygrid";
import interactionPlugin from "@fullcalendar/interaction";
import timeGridPlugin from "@fullcalendar/timegrid";
import listPlugin from "@fullcalendar/list";
import koLocale from "@fullcalendar/core/locales/ko";
import { useEffect, useState } from "react";
import {
  Button,
  Input,
  Modal,
  ModalBody,
  ModalContent,
  ModalFooter,
  ModalHeader,
  Select,
  SelectItem,
  Textarea,
  useDisclosure,
} from "@nextui-org/react";
import { useSession } from "next-auth/react";
import { EventClickArg, EventDropArg } from "@fullcalendar/core";
import { EventType } from "../../../types/types";
import { createEvent, deleteEvent, updateEvent } from "./actions";

interface DateClickArguments {
  dateStr: string;
  allDay: boolean;
}

type ScheduleComponentProps = {
  events: EventType;
};

export default function ScheduleComponent({ events }: ScheduleComponentProps) {
  const { data: session, status } = useSession(); // 세션 데이터와 상태 가져오기
  const isLoggedIn = status === "authenticated";
  const user = session?.user;
  const userGrade = user?.grade;
  const { isOpen, onOpen, onClose, onOpenChange } = useDisclosure();
  const {
    isOpen: eventOpen,
    onOpen: eventOnOpen,
    onClose: eventOnClose,
    onOpenChange: eventOnOpenChange,
  } = useDisclosure();
  const [selectedDate, setSelectedDate] = useState<string | null>(null);
  const [eventName, setEventName] = useState("");
  const [eventDescription, setEventDescription] = useState("");
  const [eventId, setEventId] = useState("");
  const [selectedEvent, setSelectedEvent] = useState(null);
  const [isEditable, setIsEditable] = useState(false);
  const [originalEvent, setOriginalEvent] = useState({
    title: "",
    description: "",
  });

  const handleEdit = () => {
    setIsEditable(true); // 입력 가능 상태로 전환
  };

  const addEvent = async () => {
    const newEvent = {
      title: eventName,
      date: selectedDate || "",
      description: eventDescription,
      author: user?.email || "",
    };
    await createEvent(newEvent);
    setEventName("");
    setEventDescription("");
    onClose();
  };

  const fixEvent = async () => {
    const newEvent = {
      id: eventId,
      title: eventName,
      date: selectedDate || "",
      description: eventDescription,
      author: user?.email || "",
    };
    await updateEvent(newEvent);
    setEventName("");
    setEventDescription("");
    eventOnClose();
  };

  const delEvent = async () => {
    await deleteEvent(eventId);
    setEventName("");
    setEventDescription("");
    eventOnClose();
  };
  const handleEventClick = (arg: EventClickArg) => {
    const { event } = arg;
    setEventName(event.title);
    setEventDescription(event.extendedProps?.description ?? "");
    setEventId(event.id);
    // 원본 이벤트 데이터 저장
    setOriginalEvent({
      title: event.title,
      description: event.extendedProps?.description ?? "",
    });
    eventOnOpen();
  };
  const handleEventDrop = async (info: EventDropArg) => {
    const { event } = info;
    const startDate = event.startStr;

    const updatedEvent = {
      id: event.id,
      title: event.title,
      date: startDate,
      description: event.extendedProps.description,
      author: user?.email || "",
    };

    try {
      await updateEvent(updatedEvent);
      console.log("Event updated successfully");
    } catch (error) {
      console.error("Error updating event:", error);
      if (originalEvent) {
        event.setStart(info.event.startStr);
      }
    }
  };
  const handleCancelEdit = () => {
    // 원본 데이터로 필드 값 복원
    setEventName(originalEvent.title);
    setEventDescription(originalEvent.description);
    setIsEditable(false);
    // eventOnClose(); // 모달 닫기
  };

  // 날짜 클릭 핸들러
  const handleDateClick = (arg: DateClickArguments) => {
    setEventName("");
    setEventDescription("");
    setSelectedDate(arg.dateStr);
    onOpen();
  };

  useEffect(() => {
    if (!isOpen) {
      setSelectedDate(null);
      setEventName("");
      setEventDescription("");
    }
  }, [isOpen]);

  useEffect(() => {
    if (!eventOpen) {
      setEventName("");
      setEventDescription("");
      setIsEditable(false);
    }
  }, [eventOpen]);

  return (
    <div className="calendar-container px-4">
      <FullCalendar
        plugins={[dayGridPlugin, interactionPlugin, timeGridPlugin, listPlugin]}
        initialView="listWeek"
        weekends={true}
        locale={koLocale}
        events={events}
        headerToolbar={{
          left: "prev,next today",
          center: "title",
          right: "listWeek,dayGridMonth",
        }}
        height={"800px"}
        editable={typeof userGrade === "number" && userGrade > 4}
        eventClick={handleEventClick}
        dateClick={handleDateClick} // 날짜 클릭 핸들러 등록
        eventDrop={handleEventDrop}
      />
      <Modal isOpen={isOpen} onOpenChange={onOpenChange} placement="top-center">
        <ModalContent>
          {(onClose) => (
            <>
              <ModalHeader className="flex flex-col gap-1">
                이벤트 등록
              </ModalHeader>
              <ModalBody>
                <p>{selectedDate}</p>
                <Input
                  label="이벤트 이름"
                  placeholder="이벤트 이름을 입력하세요"
                  variant="bordered"
                  value={eventName}
                  onChange={(e) => setEventName(e.target.value)}
                />
                <Textarea
                  label="이벤트 내용"
                  placeholder="이벤트 내용을 입력하세요"
                  variant="bordered"
                  value={eventDescription}
                  onChange={(e) => setEventDescription(e.target.value)}
                />
              </ModalBody>
              <ModalFooter>
                <Button color="danger" variant="flat" onPress={onClose}>
                  취소
                </Button>
                <Button color="primary" onPress={addEvent}>
                  확인
                </Button>
              </ModalFooter>
            </>
          )}
        </ModalContent>
      </Modal>
      <Modal
        isOpen={eventOpen}
        onOpenChange={eventOnOpenChange}
        placement="top-center"
      >
        <ModalContent>
          {(eventOnClose) => (
            <>
              <ModalHeader className="flex flex-col gap-1">이벤트</ModalHeader>
              <ModalBody>
                <p>{selectedDate}</p>
                <Input
                  label="이벤트 이름"
                  placeholder="이벤트 이름을 입력하세요"
                  variant="bordered"
                  value={eventName}
                  onChange={(e) => setEventName(e.target.value)}
                  readOnly={!isEditable}
                />
                <Textarea
                  label="이벤트 내용"
                  placeholder="이벤트 내용을 입력하세요"
                  variant="bordered"
                  value={eventDescription}
                  readOnly={!isEditable}
                  onChange={(e) => setEventDescription(e.target.value)}
                />
              </ModalBody>
              <ModalFooter>
                {typeof userGrade === "number" && userGrade >= 4 && (
                  <>
                    {!isEditable ? (
                      <Button color="primary" onPress={handleEdit}>
                        수정
                      </Button>
                    ) : (
                      <Button color="primary" onPress={fixEvent}>
                        확인
                      </Button>
                    )}
                    {!isEditable ? (
                      <Button
                        color="danger"
                        variant="flat"
                        onPress={() => {
                          delEvent();
                        }}
                      >
                        삭제
                      </Button>
                    ) : (
                      <Button color="danger" onPress={handleCancelEdit}>
                        취소
                      </Button>
                    )}
                  </>
                )}
              </ModalFooter>
            </>
          )}
        </ModalContent>
      </Modal>
    </div>
  );
}

실제 배포페이지 레이아웃




모바일 페이지 유저UI 구현

오늘 의외로 가장 많은 시간을 뺏기고

새로운 경험치를 얻어갔던 구현 파트였다.

이유는 이러하다.

메인 layout 코드

import "./globals.css";
import { Open_Sans } from "next/font/google";
import Header from "@/components/Header/Header";
import Footer from "@/components/Footer";
import { Providers } from "./provider";
import TapNav from "@/components/TabNav";
import Head from "next/head";
import { getTeamData } from "@/service/user";

const sans = Open_Sans({ subsets: ["latin"] });

export const metadata = {
  title: {
    default: "Clan be",
    template: "Clan be | %s",
  },
  description: "스타크래프트 Korea서버 Be 클랜 홈페이지",
  icons: {
    icon: "/favicon.ico",
  },
};

export default function RootLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  return (
    <html lang="en" className={`w-full ${sans.className}`}>
      <Head>
        <title>CLANBE</title>
        <meta
          name="viewport"
          content="width=device-width, initial-scale=1 user-scalable=yes"
        />
      </Head>
      <body className="flex flex-col w-full">
        <Providers>
          <div className="mx-auto max-w-7xl">
            <Header />
            <div className="flex flex-col-reverse sm:flex-row items-start">
              <TapNav />
              <main className="flex flex-col sm:flex-row items-center w-full">
                {children}
              </main>
            </div>
            <Footer />
          </div>
        </Providers>
      </body>
    </html>
  );
}

해당 레이아웃을통해 메인 컴포넌트를 props로 받아

클랜홈페이지의 메인 페이지가 렌더링 된다.

메인페이지 코드

"use client";

import {
  Divider,
} from "@nextui-org/react";
import Banner from "@/components/Banner";
import Notice from "@/components/Notice";
import Upcoming from "@/components/Upcoming";
import PublicPosts from "@/components/PublicPosts";
import PlayerPosts from "@/components/PlayerPosts";
import MobileUserComponent from "@/components/MobileUserComponent";

export default function HomePage() {
  return (
    <div className="mx-auto">
      <Banner />
      <Divider className="my-4" />
      <div className="flex flex-wrap gap-2 justify-center">
        <div className="block md:hidden w-full mx-4">
          <MobileUserComponent />
        </div>
        <Notice />
        <Upcoming />
        <PublicPosts />
        <PlayerPosts />
      </div>
    </div>
  );
}

여기서 문제가 생겼다.

해당 페이지를 서버컴포넌트로 돌릴 수 있어야

유저UI 및 다른 게시판 레이아웃에도 데이터를 받아

렌더링 해줄 수 있다.

그런데 문제는 해당 페이지를 서버컴포넌트로 전환하면

⨯ Error: Functions cannot be passed directly to Client Components unless you explicitly expose it by marking it with "use server".
<... columns={[...]} children={function}>
^^^^^^^^^^

이러한 오류가 뜨는데 , 이부분에 대해서

원래 메인페이지는 서버컴포넌트를 사용할 수 없게 되어있는건지

아니면 하위 컴포넌트들의 문제였는지 정확하게 파악하지 못했다.

그래서 이 문제를 서버액션을 통해 해결했다.

모바일 유저UI 코드

import {
  Button,
  Card,
  Divider,
  Input,
  Link,
  Textarea,
  User,
} from "@nextui-org/react";
import { CardFooter, Link as MyLink } from "@nextui-org/react";
import { useEffect, useState } from "react";
import { useRouter } from "next/navigation";
import { useSession } from "next-auth/react";
import { getTeamData } from "@/service/user";
import { Team } from "../../types/types";
import { getTeam } from "@/app/actions";
import UserNav from "./UserNav";

export default function MobileUserComponent() {
  const router = useRouter();
  const { data: session, status } = useSession(); // 세션 데이터와 상태 가져오기
  const isLoggedIn = status === "authenticated";
  const user = session?.user;

  const [teams, setTeams] = useState<Team[]>([]);

  useEffect(() => {
    // 내부에서 비동기 함수 선언
    const fetchData = async () => {
      const { teams } = await getTeam();
      setTeams(teams); // 팀 데이터 설정
    };

    if (isLoggedIn) {
      fetchData().catch(console.error); // 에러 처리를 위한 catch 추가
    }
  }, [isLoggedIn, setTeams]); // isLoggedIn이 변경될 때만 실행

  if (!isLoggedIn) {
    return (
      <Card className="p-4 flex flex-col items-center justify-center w-full h-32">
        <p>로그인을 해주세요.</p>
        <Link href="/api/auth/signin">
          <Button color="primary" size="sm">
            로그인
          </Button>
        </Link>
      </Card>
    );
  }

  return user ? (
    <UserNav user={user} teams={teams} />
  ) : (
    // 유효하지 않은 user 객체의 경우 대체 UI 표시
    <Card className="p-4 flex flex-col items-center justify-center w-full h-32">
      <p>로그인을 해주세요.</p>
      <Link href="/api/auth/signin">
        <Button color="primary" size="sm">
          로그인
        </Button>
      </Link>
    </Card>
  );
}

이와 같이 서버액션으로 지정한 메서드를 메인페이지 내부 컴포넌트에서 useEffect로 불러와 값을 매칭해주고 렌더링을 해주는 방식으로 메인페이지를 서버컴포넌트로 바꾸지 않고도 문제를 해결하였다.

배포된 페이지 레이아웃




반응형으로 모바일일 경우 모바일 전용 레이아웃으로
렌더링이 되도록 조건부 렌더링을 걸어주었다.

profile
The pain is so persistent that it is like a snail, and the joy is so short that it is like a rabbit's tail running through the fields of autumn

0개의 댓글