오늘은
공지사항 게시판 기능구현 / 공지사항 기능개편
스케쥴 페이지 구현 및 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>
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>
);
}
오늘 의외로 가장 많은 시간을 뺏기고
새로운 경험치를 얻어갔던 구현 파트였다.
이유는 이러하다.
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}>
^^^^^^^^^^
이러한 오류가 뜨는데 , 이부분에 대해서
원래 메인페이지는 서버컴포넌트를 사용할 수 없게 되어있는건지
아니면 하위 컴포넌트들의 문제였는지 정확하게 파악하지 못했다.
그래서 이 문제를 서버액션을 통해 해결했다.
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로 불러와 값을 매칭해주고 렌더링을 해주는 방식으로 메인페이지를 서버컴포넌트로 바꾸지 않고도 문제를 해결하였다.
반응형으로 모바일일 경우 모바일 전용 레이아웃으로
렌더링이 되도록 조건부 렌더링을 걸어주었다.