Skip to content

02.28 최신화#154

Merged
shinwokkang merged 114 commits intomainfrom
dev
Mar 1, 2026
Merged

02.28 최신화#154
shinwokkang merged 114 commits intomainfrom
dev

Conversation

@shinwokkang
Copy link
Contributor

@shinwokkang shinwokkang commented Feb 28, 2026

📌 개요 (Summary)

  • 변경 사항에 대한 간략한 요약을 적어주세요.
  • 관련 이슈가 있다면 링크를 걸어주세요 (예: [fix] cicd / build 문제 #123).

🛠️ 변경 사항 (Changes)

  • 새로운 기능 추가
  • 버그 수정
  • 코드 리팩토링
  • 문서 업데이트
  • 기타 (설명: )

📸 스크린샷 (Screenshots)

(UI 변경 사항이 있다면 첨부해주세요)

✅ 체크리스트 (Checklist)

  • 빌드가 성공적으로 수행되었나요? (pnpm build)
  • 린트 에러가 없나요? (pnpm lint)
  • 불필요한 콘솔 로그나 주석을 제거했나요?

Summary by CodeRabbit

  • New Features
    • Admin user detail pages with profile, tabs and activity lists; expanded admin group flows (create/edit clubs, member management, meeting teams, bookcase create/edit/delete).
    • Like/follow interactions across stories and profiles with login gating; follower/following lists with infinite scroll.
    • Rich notice workflow: voting, image uploads, detail views; enhanced discussion/review reporting and edit/delete flows.
  • Bug Fixes
    • Improved validation, pagination, loading/error and empty-state handling.
  • Chores
    • Relaxed React version range, added no-scrollbar utility, expanded remote image allowlist, removed sensitive example env entry.

김기현 and others added 30 commits February 25, 2026 15:27
[fix/style] build 오류 해결 , 내모임이 많을 떄 스크롤 안생기게끔
@hongik-luke
Copy link
Contributor

제꺼 먼저점여

[feat] 관리자용 모임 관리, 소식 관리 상세 페이지 UI 구현
Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 20

Note

Due to the large number of review comments, Critical, Major severity comments were prioritized as inline comments.

🟡 Minor comments (12)
src/app/(admin)/admin/(app)/news/[id]/page.tsx-95-95 (1)

95-95: ⚠️ Potential issue | 🟡 Minor

Remove placeholder publication period text.

Line [95] renders a fixed "2000.00.00 - 2000.00.00" value, which is incorrect in user-facing UI. Bind to real period fields (or hide this row until data exists).

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/app/`(admin)/admin/(app)/news/[id]/page.tsx at line 95, The static
placeholder "2000.00.00 - 2000.00.00" in the JSX should be replaced with the
actual publication period from the news data (or hidden when not present):
locate the page component (the default export in page.tsx / the News detail
render function) and replace the hardcoded string in the "게시기간" row with the
real fields (e.g., news.startDate and news.endDate or
publicationStart/publicationEnd) formatted for display, and add a conditional
render to omit the entire row if those fields are null/undefined.
src/app/(admin)/admin/(app)/groups/[id]/page.tsx-114-126 (1)

114-126: ⚠️ Potential issue | 🟡 Minor

Same placeholder issue as categories.

The Target component also receives 6 identical items. Apply the same fix to use diverse placeholder values.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/app/`(admin)/admin/(app)/groups/[id]/page.tsx around lines 114 - 126, The
Target component in page.tsx is being fed six identical placeholder strings;
replace the duplicated items array passed to the Target component (the items
prop in the page.tsx render) with diverse placeholder values (e.g., varied
category/target names) similar to the fix applied for the categories list so the
UI shows distinct items; locate the Target usage in the page.tsx file and update
the items array passed to Target to contain different strings.
src/app/(admin)/admin/(app)/groups/[id]/page.tsx-51-67 (1)

51-67: ⚠️ Potential issue | 🟡 Minor

Placeholder categories should be diverse for meaningful UI testing.

All 15 items are identical ("국내도서"), which doesn't help verify the UI renders different categories correctly. Consider using varied placeholder values or fetching real data.

📝 Suggested diverse placeholders
   const categories = [
-    "국내도서",
-    "국내도서",
-    "국내도서",
-    "국내도서",
-    "국내도서",
-    "국내도서",
-    "국내도서",
-    "국내도서",
-    "국내도서",
-    "국내도서",
-    "국내도서",
-    "국내도서",
-    "국내도서",
-    "국내도서",
-    "국내도서",
+    "소설",
+    "에세이",
+    "자기계발",
+    "인문학",
+    "경제/경영",
+    "과학",
+    "역사",
+    "예술",
   ];
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/app/`(admin)/admin/(app)/groups/[id]/page.tsx around lines 51 - 67, The
categories array in page.tsx currently contains 15 identical entries ("국내도서");
update the categories constant to use diverse placeholder values (e.g., multiple
distinct category names) or wire it to real/test data so the UI can be validated
with varied inputs — locate the categories variable in the groups/[id]/page.tsx
and replace the repeated "국내도서" entries with a set of unique strings or a data
fetch that returns varied category names.
src/app/(admin)/admin/(app)/groups/[id]/members/page.tsx-65-65 (1)

65-65: ⚠️ Potential issue | 🟡 Minor

Render joined date with locale formatting instead of raw string.

Line [65] and Line [142] currently pass/display a raw date string. Use a formatter for consistent KR admin UX and safer future date-source changes.

Minimal fix
+  const formatDate = (iso: string) =>
+    new Intl.DateTimeFormat("ko-KR", { year: "numeric", month: "2-digit", day: "2-digit" }).format(new Date(iso));

 ...
-                  <td className="pl-[12px] text-Gray-7">{u.joinedAt}</td>
+                  <td className="pl-[12px] text-Gray-7">{formatDate(u.joinedAt)}</td>

Also applies to: 142-142

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/app/`(admin)/admin/(app)/groups/[id]/members/page.tsx at line 65, Replace
raw date strings used for joinedAt with a formatted date via a date formatter:
when constructing the member data (where joinedAt: `2024-0${(i % 9) + 1}-15`)
compute a Date object and format it (e.g., new
Date(...).toLocaleDateString('ko-KR', { year: 'numeric', month: '2-digit', day:
'2-digit' }) or Intl.DateTimeFormat('ko-KR', ...).format(...)) and assign that
formatted string to joinedAt; also update the display code that renders joinedAt
(in the same component/page rendering members) to assume a locale-formatted
string (or format there from a Date object) so both creation and rendering use
consistent KR locale formatting.
src/app/groups/[id]/bookcase/page.tsx-99-103 (1)

99-103: ⚠️ Potential issue | 🟡 Minor

iconAlt text does not match the action.

iconAlt="문의하기" means "Contact/Inquiry" but the button navigates to create a new bookcase entry (/groups/${groupId}/admin/bookcase/new). The alt text should describe the actual action for accessibility.

Proposed fix
         <FloatingFab
           iconSrc="/icons_pencil.svg"
-          iconAlt="문의하기"
+          iconAlt="새 책장 추가"
           onClick={() => router.push(`/groups/${groupId}/admin/bookcase/new`)}
         />

Also applies to: 147-151

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/app/groups/`[id]/bookcase/page.tsx around lines 99 - 103, The alt text
for the FloatingFab's icon is incorrect: update the iconAlt prop on the
FloatingFab instances (the one that calls
router.push(`/groups/${groupId}/admin/bookcase/new`) in this file, and the other
instance at the later occurrence) to describe the actual action (e.g., "새 도서 등록"
or "Add new bookcase/entry") instead of "문의하기" so screen readers convey the
correct purpose; locate the FloatingFab components and change iconAlt
accordingly to match the navigation target invoked by the onClick using
router.push and groupId.
src/app/groups/[id]/bookcase/page.tsx-69-75 (1)

69-75: ⚠️ Potential issue | 🟡 Minor

Inconsistent groupId validation between render guard and query enablement.

The guard at line 69 uses Number.isNaN(groupId), but the query enablement at line 29 uses Number.isFinite(groupId) && groupId > 0. These checks have different coverage:

  • Number.isNaN only catches NaN
  • Number.isFinite also rejects Infinity/-Infinity
  • Neither explicitly rejects 0 or negative IDs here

Align the validation logic to be consistent.

Proposed fix
-  if (Number.isNaN(groupId)) {
+  if (!Number.isFinite(groupId) || groupId <= 0) {
     return (
       <div className="w-full flex flex-col gap-[24px] text-Gray-7 body_1_2">
         잘못된 모임 ID
       </div>
     );
   }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/app/groups/`[id]/bookcase/page.tsx around lines 69 - 75, The render guard
currently checks Number.isNaN(groupId) while the query enablement uses
Number.isFinite(groupId) && groupId > 0, causing inconsistent validation; update
the render guard to use the same check (Number.isFinite(groupId) && groupId > 0)
so both the UI early-return and the logic that enables queries treat invalid,
infinite, zero or negative groupId the same (referencing the groupId variable
and the render guard block that returns "잘못된 모임 ID" and the query enablement
expression).
src/app/groups/[id]/notice/[noticeId]/page.tsx-36-37 (1)

36-37: ⚠️ Potential issue | 🟡 Minor

Replace any cast with proper generic typing for useParams.

ESLint flags the any cast. Next.js App Router's useParams supports generic typing for type-safe param access.

🛠️ Proposed fix
-  const clubId = Number((params as any).id);
-  const noticeId = Number((params as any).noticeId);
+  const { id, noticeId: noticeIdParam } = useParams<{ id: string; noticeId: string }>();
+  const clubId = Number(id);
+  const noticeId = Number(noticeIdParam);

Or alternatively, keep the existing structure but type the params:

+  const params = useParams<{ id: string; noticeId: string }>();
-  const clubId = Number((params as any).id);
-  const noticeId = Number((params as any).noticeId);
+  const clubId = Number(params.id);
+  const noticeId = Number(params.noticeId);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/app/groups/`[id]/notice/[noticeId]/page.tsx around lines 36 - 37, The
code is casting params to any when deriving clubId and noticeId; replace that
with proper generic typing on useParams so params is typed (e.g., useParams<{
id: string; noticeId: string }>()), then read params.id and params.noticeId and
convert them to numbers for clubId and noticeId; update references in this file
(params, clubId, noticeId) accordingly to remove the any cast and satisfy
ESLint/type-checking.
src/components/base-ui/Admin/groups/TopItem.tsx-1-1 (1)

1-1: ⚠️ Potential issue | 🟡 Minor

Remove the "use client" directive from this file.

The component is purely presentational with no client-side dependencies (no hooks, event handlers, or browser APIs). Its sole importer, src/app/(admin)/admin/(app)/groups/[id]/page.tsx, is a Server Component. Removing this directive will reduce unnecessary client bundle size without functional impact.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/components/base-ui/Admin/groups/TopItem.tsx` at line 1, Remove the "use
client" directive at the top of this file (TopItem component) so it becomes a
pure server/presentational component; locate the "use client" string in
src/components/base-ui/Admin/groups/TopItem.tsx and delete that line (do not add
any client-only APIs or hooks), ensuring the exported TopItem component remains
unchanged and can be imported by the server component consumer.
src/components/base-ui/Admin/groups/Chips.tsx-26-27 (1)

26-27: ⚠️ Potential issue | 🟡 Minor

Prevent hover animation on disabled chips.

Line 26 applies hover:* styles unconditionally, so disabled chips can still show motion feedback. Use enabled:hover:* to keep disabled controls visually inert.

Suggested diff
-        "hover:brightness-98 hover:-translate-y-[1px] cursor-pointer",
+        "enabled:hover:brightness-98 enabled:hover:-translate-y-[1px] cursor-pointer",
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/components/base-ui/Admin/groups/Chips.tsx` around lines 26 - 27, In the
Chips component (src/components/base-ui/Admin/groups/Chips.tsx) the hover styles
are applied unconditionally, so disabled chips still show motion; update the
class string used for chip buttons (the one containing "hover:brightness-98
hover:-translate-y-[1px] cursor-pointer") to scope hover and cursor to enabled
state (e.g., use enabled:hover:brightness-98 enabled:hover:-translate-y-[1px]
and enabled:cursor-pointer) and keep the existing "disabled:..." rules as-is so
disabled chips remain visually inert.
src/hooks/queries/useClubNotificationQueries.ts-18-19 (1)

18-19: ⚠️ Potential issue | 🟡 Minor

Harden enabled guards to positive IDs/pages.

Line 18 and Line 27 only check finiteness; 0/negative values can still fetch invalid resources.

🛠️ Suggested fix
   return useQuery<GetClubNoticesResponseResult>({
     queryKey: clubNoticeQueryKeys.list(clubId, page),
-    enabled: Number.isFinite(clubId) && Number.isFinite(page),
+    enabled:
+      Number.isFinite(clubId) &&
+      clubId > 0 &&
+      Number.isFinite(page) &&
+      page > 0,
     queryFn: () => clubNotificationService.getNotices({ clubId, page }),
   });
 }
@@
   return useQuery<GetClubNoticeDetailResponseResult>({
     queryKey: clubNoticeQueryKeys.detail(clubId, noticeId),
-    enabled: Number.isFinite(clubId) && Number.isFinite(noticeId),
+    enabled:
+      Number.isFinite(clubId) &&
+      clubId > 0 &&
+      Number.isFinite(noticeId) &&
+      noticeId > 0,
     queryFn: () => clubNotificationService.getNoticeDetail({ clubId, noticeId }),
   });
 }

Also applies to: 27-28

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/hooks/queries/useClubNotificationQueries.ts` around lines 18 - 19, The
enabled guard currently uses Number.isFinite(clubId) and Number.isFinite(page)
which allows 0/negative values; change the checks so enabled requires clubId > 0
&& page > 0 (e.g., enabled: Number.isFinite(clubId) && clubId > 0 &&
Number.isFinite(page) && page > 0) so the queryFn
(clubNotificationService.getNotices({ clubId, page })) only runs for positive
IDs/pages; apply the same stricter guard to the other enabled block that
controls the other query (the other queryFn calling
clubNotificationService.getAnnouncements / similar) so both prevent fetching
invalid resources.
src/app/groups/[id]/notice/page.tsx-44-45 (1)

44-45: ⚠️ Potential issue | 🟡 Minor

Don’t hide pagination solely from current-page emptiness.

Line 123 can hide controls when the current page is empty even if other pages exist, making recovery impossible in edge cases.

🛠️ Suggested fix
-import { useMemo, useState } from "react";
+import { useEffect, useMemo, useState } from "react";
@@
   const isEmpty = pinnedNotices.length === 0 && notices.length === 0;
+
+  useEffect(() => {
+    if (currentPage > totalPages) {
+      setCurrentPage(totalPages);
+    }
+  }, [currentPage, totalPages]);
@@
-      {!isEmpty && (
+      {totalPages > 1 && (
         <div className="fixed bottom-20 left-0 right-0 t:bottom-24 d:bottom-4 d:left-50 py-4 flex items-center justify-center gap-0 z-50">

Also applies to: 123-124

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/app/groups/`[id]/notice/page.tsx around lines 44 - 45, The current
isEmpty check (const isEmpty = pinnedNotices.length === 0 && notices.length ===
0) hides pagination when the current page has no items even if other pages
contain notices; change the logic to decide pagination visibility from the
global totals instead of the current-page arrays—e.g. use a provided
totalNotices/totalCount or totalPages prop (or compute totalPages from
serverTotalCount) and render pagination when totalCount > 0 or totalPages > 1;
update any uses of isEmpty (and any conditional that hides the pagination
controls) to rely on that global total/totalPages value rather than
pinnedNotices.length or notices.length.
src/app/groups/[id]/admin/notice/new/page.tsx-171-173 (1)

171-173: ⚠️ Potential issue | 🟡 Minor

Tighten clubId validation to positive IDs.

Line 171 should also reject non-positive values to prevent invalid API calls.

🛠️ Suggested fix
-    if (!Number.isFinite(clubId)) {
+    if (!Number.isFinite(clubId) || clubId <= 0) {
       toast.error("clubId가 올바르지 않습니다.");
       return;
     }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/app/groups/`[id]/admin/notice/new/page.tsx around lines 171 - 173, The
current validation only checks Number.isFinite(clubId) but allows zero or
negative IDs; update the guard that uses clubId (the check around
Number.isFinite(clubId)) to also require clubId > 0 (e.g., if
(!Number.isFinite(clubId) || clubId <= 0) { toast.error("clubId가 올바르지 않습니다.");
return; }) so non-positive values are rejected before any API calls or further
processing involving clubId.
🧹 Nitpick comments (18)
src/app/(admin)/admin/(app)/news/page.tsx (1)

208-217: Add current-page accessibility state on pagination buttons.

Please expose the selected page with aria-current="page" on the active page button so screen readers can announce the current position.

♿ Suggested patch
             {pageButtons.map((p) => (
               <button
                 key={p}
                 onClick={() => goTo(p)}
+                aria-current={p === page ? "page" : undefined}
                 className={`cursor-pointer ${
                   p === page ? "text-Gray-7" : "text-Gray-4"
                 } hover:opacity-70`}
                 type="button"
               >
                 {p}
               </button>
             ))}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/app/`(admin)/admin/(app)/news/page.tsx around lines 208 - 217, The
pagination button for each page (rendered in page.tsx) needs an accessibility
state indicating the active page: update the <button> returned in the pagination
mapping (the element using key={p} and onClick={() => goTo(p)}) to include
aria-current="page" only when p === page (e.g., aria-current={p === page ?
"page" : undefined}) so screen readers can announce the selected page; keep the
conditional text class logic as-is and do not add aria-current for non-active
pages.
src/components/base-ui/Bookcase/BookDetailCard.tsx (1)

62-90: Avoid mounting two BookshelfAdminMenu instances per card.

Line [62]–Line [90] renders separate mobile/desktop menu instances and hides one with CSS. Both can still mount, so each card carries duplicate menu state (and duplicate outside-click listeners from src/components/base-ui/Bookcase/bookid/BookshelfAdminMenu.tsx), which is avoidable overhead in list views.

Please render a single BookshelfAdminMenu instance and switch placement via breakpoint state or a layout refactor that keeps one DOM instance.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/components/base-ui/Bookcase/BookDetailCard.tsx` around lines 62 - 90,
BookDetailCard mounts two BookshelfAdminMenu instances (mobile and desktop)
which duplicates state and outside-click listeners; instead render a single
BookshelfAdminMenu when isStaff && onEditBookshelf && onDeleteBookshelf and move
its visual placement via CSS or a breakpoint state/refactor (e.g., compute a
`isDesktop` boolean via a responsive hook or resize observer inside
BookDetailCard) so only one instance of BookshelfAdminMenu (pass
isDeletingBookshelf, onEditBookshelf, onDeleteBookshelf) is in the DOM and its
wrapper classes switch between the mobile container and the desktop container.
src/app/(admin)/admin/(app)/groups/[id]/page.tsx (2)

135-144: Add labels for accessibility.

The inputs rely solely on placeholder for context. Screen readers need proper <label> elements or aria-label attributes.

♿ Suggested accessibility improvement
+          <label className="sr-only" htmlFor="link-text">링크 대체 텍스트</label>
           <input
+            id="link-text"
             type="text"
             placeholder="링크 대체 텍스트 입력(최대 20자)"
             className="flex-1 h-[56px] rounded-[8px] border border-Subbrown-4 bg-White px-4 text-[18px] outline-none"
           />
+          <label className="sr-only" htmlFor="link-url">링크 URL</label>
           <input
+            id="link-url"
             type="text"
             placeholder="링크 입력(최대 100자)"
             className="flex-1 h-[56px] rounded-[8px] border border-Subbrown-4 bg-White px-4 text-[18px] outline-none"
           />
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/app/`(admin)/admin/(app)/groups/[id]/page.tsx around lines 135 - 144, The
two text inputs that currently only use placeholders ("링크 대체 텍スト 입력(최대 20자)" and
"링크 입력(최대 100자)") must have accessible labels: either add associated <label>
elements with htmlFor linking to unique id attributes on each input or add
descriptive aria-label attributes on each input (e.g., aria-label="링크 대체 텍스트"
and aria-label="링크 URL"), ensuring ids/labels match and that the labels convey
the same information as the placeholders for screen readers.

17-36: Consider adding a TODO comment for API integration.

The dummy data and lookup helper are acceptable for scaffolding, but a TODO comment would help track the pending API integration work.

📝 Suggested addition
+// TODO: Replace with real API call to fetch group details
 const DUMMY_GROUPS: GroupDetail[] = [
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/app/`(admin)/admin/(app)/groups/[id]/page.tsx around lines 17 - 36, Add a
TODO comment above the DUMMY_GROUPS declaration (and/or above getGroupById)
indicating this is stub data and should be replaced by a real API call (e.g.,
"TODO: replace DUMMY_GROUPS/getGroupById with API integration to fetch group
details"), optionally referencing the ticket or issue number; keep the comment
concise and include what needs to be replaced (DUMMY_GROUPS and getGroupById)
and that the replacement should fetch group data from the backend.
next.config.ts (1)

11-16: Scope images.remotePatterns pathnames more narrowly (if possible).

Lines 11 and 16 currently allow all paths ("/**") on both hosts. If only specific path prefixes are actually used, consider tightening these to reduce the surface for potential image-optimizer abuse. However, no literal URL references to these hosts were found in the codebase—they may be constructed dynamically or loaded from external sources—so verify the actual path patterns in use before narrowing the configuration.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@next.config.ts` around lines 11 - 16, The remotePatterns entries in
next.config.ts (images.remotePatterns) use overly broad pathname values ("/**");
identify the real path prefixes used for these hosts (e.g., the static image
directory or API path your app actually requests) and replace each "/**" with
the narrowest matching pattern (for example a specific prefix like "/uploads/**"
or "/images/**") to limit allowed image paths; update the pathname fields in the
existing remotePatterns objects (the entries containing hostname
"cdn.checkmo.ai" and "checkmo-s3-presigned.s3.ap-northeast-2.amazonaws.com")
after verifying dynamic URL construction so the config only permits the required
subpaths.
src/components/base-ui/Group/notice_item.tsx (1)

56-64: Consider replacing nested tag ternaries with a tag metadata map.

Current rendering works, but this branch gets harder to maintain as tag types expand.

🧩 Suggested refactor
 type NoticeTag = "general" | "vote" | "meeting";
+
+const NOTICE_TAG_META: Record<NoticeTag, { label: string; colorClass: string }> = {
+  general: { label: "일반", colorClass: "bg-primary-2" },
+  vote: { label: "투표", colorClass: "bg-Secondary-3" },
+  meeting: { label: "모임", colorClass: "bg-Secondary-2" },
+};

 ...
             {tags.map((tag, index) => (
+              (() => {
+                const meta = NOTICE_TAG_META[tag];
+                return (
               <div
                 key={`${tag}-${index}`}
-                className={`w-13 h-7 rounded flex items-center justify-center px-2 py-1 text-white body_1_2 ${
-                  tag === "vote"
-                    ? "bg-Secondary-3"
-                    : tag === "meeting"
-                      ? "bg-Secondary-2"
-                      : "bg-primary-2" // ✅ GENERAL(일반)
-                }`}
+                className={`w-13 h-7 rounded flex items-center justify-center px-2 py-1 text-white body_1_2 ${meta.colorClass}`}
               >
-                {tag === "vote" ? "투표" : tag === "meeting" ? "모임" : "일반"}
+                {meta.label}
               </div>
+                );
+              })()
             ))}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/components/base-ui/Group/notice_item.tsx` around lines 56 - 64, Replace
the nested ternary expressions for the tag styling and label by introducing a
tag metadata map (e.g., const TAG_META = { vote: { className: "bg-Secondary-3",
label: "투표" }, meeting: { className: "bg-Secondary-2", label: "모임" }, default: {
className: "bg-primary-2", label: "일반" } }) and then use
TAG_META[tag]?.className ?? TAG_META.default.className inside the className
template and TAG_META[tag]?.label ?? TAG_META.default.label for the rendered
text; update the JSX where the current nested ternaries are used (the className
string interpolation and the {tag === "vote" ? ... } label expression) to
reference the map instead so adding new tag types only requires updating
TAG_META.
src/components/base-ui/Group/BookshelfModal.tsx (2)

61-102: Consider adding ARIA dialog attributes for accessibility.

The modal lacks semantic dialog markup. Adding role="dialog" and aria-modal="true" would improve accessibility for screen reader users. Additionally, focus trapping would enhance keyboard navigation.

♿ Suggested accessibility improvement
     <div className="absolute inset-0 flex items-stretch t:items-center justify-center">
         <div
+          role="dialog"
+          aria-modal="true"
+          aria-labelledby="bookshelf-modal-title"
           className="
             w-full h-full
             bg-background

And add an id to the title:

-            <p className="hidden t:block subhead_4_1 text-Gray-7">책장 등록</p>
+            <p id="bookshelf-modal-title" className="hidden t:block subhead_4_1 text-Gray-7">책장 등록</p>
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/components/base-ui/Group/BookshelfModal.tsx` around lines 61 - 102, The
modal markup in BookshelfModal is missing semantic dialog attributes and focus
management: add role="dialog" and aria-modal="true" to the top-level modal
container (the div currently rendered with className="fixed inset-0 z-50"), give
the title element (the <p className="hidden t:block subhead_4_1 text-Gray-7">책장
등록</p>) a stable id and add aria-labelledby on the dialog to reference it, and
make the dialog focusable (e.g., tabIndex={-1}) and implement focus
trapping/restoration inside the BookshelfModal component (use an effect to move
focus into the dialog on mount, trap Tab/Shift+Tab within the dialog, and
restore focus on unmount). Ensure onClose is still invoked for backdrop click
and Esc handling as part of the focus/keyboard management.

135-137: No-op handlers are passed to BookcaseCard.

Empty arrow functions are provided for onTopicClick, onReviewClick, and onMeetingClick. If these actions aren't relevant in the book selection context, consider making these props optional in BookcaseCard (if not already) to avoid unnecessary prop passing.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/components/base-ui/Group/BookshelfModal.tsx` around lines 135 - 137,
BookshelfModal is passing empty no-op handlers for onTopicClick, onReviewClick,
and onMeetingClick into BookcaseCard; update the code so these props are not
passed when unused or make them optional on BookcaseCard. Either remove the
onTopicClick/onReviewClick/onMeetingClick props from the BookcaseCard JSX in
BookshelfModal (leave only handlers that are meaningful) or modify
BookcaseCard's prop types (and internal checks) to accept these callbacks as
optional, e.g., mark them optional in the component props and guard calls before
invoking them.
src/app/groups/[id]/bookcase/page.tsx (2)

111-154: JSX indentation is inconsistent.

The grid container <div> and its closing tag have misaligned indentation relative to the return statement, making the code harder to read.

Proposed formatting fix
   return (
-      <div
-        className="
+    <div
+      className="
           w-full grid justify-center
           ...
-        "
-      >
-    {list.map((item) => {
+      "
+    >
+      {list.map((item) => {
         ...
-    })}
-     {isStaff && (
+      })}
+      {isStaff && (
         <FloatingFab ... />
-      )}
-  </div>
-    );
+       )}
+    </div>
+  );
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/app/groups/`[id]/bookcase/page.tsx around lines 111 - 154, The JSX return
block has misaligned indentation around the root <div> and its closing </div>,
making the mapped children and conditional <FloatingFab> hard to follow;
reformat the return so the opening <div> aligns directly under the return, the
{list.map(...)} callback body and each <BookcaseCard /> prop are indented one
level inside that div, and the closing </div> aligns with the opening <div>;
apply the same consistent indentation to the conditional block that renders
<FloatingFab> and keep related symbols (list.map, BookcaseCard, FloatingFab,
handleGoToDetail, isStaff, router.push) unchanged except for whitespace
formatting.

33-40: Auto-fetch loop may repeatedly fire due to dependency on changing values.

The useEffect triggers fetchNextPage() whenever hasNextPage or isFetchingNextPage changes. After each fetch completes, these values update, causing the effect to re-run. The AUTO_FETCH_LIMIT guards against infinite loops, but this pattern can still lead to rapid-fire fetches.

Consider using an intersection observer or scroll-based trigger for better UX and performance. If eager pre-fetching is intentional, add a comment explaining the design decision.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/app/groups/`[id]/bookcase/page.tsx around lines 33 - 40, The current
useEffect (useEffect in page.tsx) repeatedly calls fetchNextPage because
hasNextPage and isFetchingNextPage flip after each fetch; replace this eager
refetch pattern with a scroll/sentinel IntersectionObserver to trigger
fetchNextPage when the user nears the end (attach observer to a sentinel element
and call fetchNextPage once per intersection), or if eager pre-fetching is
intentional, document the behavior with a clear comment and prevent rapid
re-runs by gating with a local boolean ref (e.g., an isFetchingRef) or by
including autoFetchCountRef.current in the effect logic so the effect does not
retrigger immediately after state changes; reference useEffect, hasNextPage,
isFetchingNextPage, fetchNextPage, autoFetchCountRef, and AUTO_FETCH_LIMIT when
making the change.
src/hooks/queries/useClubsBookshelfQueries.ts (1)

83-103: Type inconsistency: initialPageParam is null but getNextPageParam can return undefined.

initialPageParam: null (line 93) and getNextPageParam returning undefined (line 101) create a type mismatch. The TPageParam generic is number | null, but undefined is also returned. While this works at runtime (both signal "no more pages"), it's inconsistent.

Proposed fix for consistency
     getNextPageParam: (lastPage) =>
-      lastPage.hasNext ? lastPage.nextCursor ?? undefined : undefined,
+      lastPage.hasNext ? (lastPage.nextCursor ?? null) : null,
   });
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/hooks/queries/useClubsBookshelfQueries.ts` around lines 83 - 103, The
infinite query uses TPageParam number | null but initialPageParam is null while
getNextPageParam can return undefined, causing a type inconsistency; update
useBookshelfTopicsInfiniteQuery so getNextPageParam returns null (not undefined)
when there is no next page (i.e., change the branch that currently yields
undefined to return null), ensuring returned page params match the generic type,
and keep initialPageParam as null and queryKey bookshelfQueryKeys.topics
unchanged.
src/app/groups/[id]/notice/[noticeId]/page.tsx (2)

13-18: Consider adding input validation for robustness.

The string slicing approach assumes a well-formed ISO date string. If input is malformed, too short, or empty, this will silently produce incorrect output.

♻️ Suggested improvement with validation
 function formatKoreanDate(input: string) {
+  if (!input || input.length < 10) {
+    return input || '';
+  }
   const y = input.slice(0, 4);
   const m = input.slice(5, 7);
   const d = input.slice(8, 10);
   return `${y}.${m}.${d}`;
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/app/groups/`[id]/notice/[noticeId]/page.tsx around lines 13 - 18, The
function formatKoreanDate assumes a well-formed ISO date string and will
misbehave on empty or short inputs; update formatKoreanDate to validate input
(e.g., check it's a non-empty string, length >= 10, and matches an ISO-like
pattern such as /^\d{4}-\d{2}-\d{2}/ or is a parsable Date), and handle invalid
input by either throwing a clear error or returning a safe fallback (e.g., empty
string or "Invalid date"); ensure the function still returns the same formatted
"YYYY.MM.DD" for valid inputs and update any callers if you choose to change the
error/return behavior.

94-95: Hardcoded isAdmin = true — intentional but consider future-proofing.

The comment clarifies this is an admin route, but hardcoding may become problematic if:

  • The route is reused in non-admin contexts
  • Authorization checks are needed server-side

For now this is acceptable, but consider deriving isAdmin from user session/context in future iterations.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/app/groups/`[id]/notice/[noticeId]/page.tsx around lines 94 - 95, The
variable isAdmin is hardcoded to true (const isAdmin = true) which is fragile;
replace this with a derived value from the authenticated user/session or request
context (e.g., call your auth/session helper such as
getCurrentUser/getServerSession or check user.role on the session) and use that
value for authorization checks in the page component (replace usages of isAdmin
with the derived isAdmin flag from the session object), retaining the admin-only
behavior but making it future-proof and secure.
src/components/base-ui/Admin/groups/Category.tsx (1)

24-25: Avoid no-op handlers for non-functional chips.

Line 24 uses an empty click handler. If this section is currently read-only, mark the chip as disabled to avoid misleading interaction cues.

Suggested diff
           <Chip
             key={`${label}-${idx}`}
             label={label}
             selected={false}
             onClick={() => {}}
+            disabled
           />
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/components/base-ui/Admin/groups/Category.tsx` around lines 24 - 25, The
Chip in Category.tsx currently has a no-op click handler (onClick={() => {}})
which is misleading; remove the empty onClick and instead mark the chip as
non-interactive (e.g., apply disabled or set clickable={false} depending on the
Chip implementation) or conditionally render a real handler when the chip should
be actionable; update the JSX where the Chip with onClick={() => {}} is defined
to reflect the correct non-interactive prop and remove the noop handler.
src/components/base-ui/Admin/groups/Target.tsx (1)

23-25: Make inactive chips explicitly disabled until behavior is wired.

Line 24 is intentionally no-op (기능 없음), but the control still looks clickable. Add disabled to match current behavior.

Suggested diff
           <Chip
             key={`${label}-${idx}`}
             label={label}
             selected={false} // 지금은 UI만
             onClick={() => {}} // 기능 없음
+            disabled
           />
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/components/base-ui/Admin/groups/Target.tsx` around lines 23 - 25, The
chip is rendered as clickable despite having a no-op onClick; update the JSX in
Target.tsx where you have selected={false} and onClick={() => {}} to also pass
disabled (and aria-disabled if accessible) to the chip component so it appears
and behaves inactive until behavior is wired; additionally remove or guard the
onClick handler (or make it undefined) so the disabled state isn't bypassed by
an active handler.
src/components/base-ui/Admin/groups/Chips.tsx (1)

16-19: Expose selection state with aria-pressed.

selected currently changes only visuals. Add aria-pressed={selected} so assistive technologies can read the pressed/unpressed state.

Suggested diff
     <button
       type="button"
       onClick={onClick}
       disabled={disabled}
+      aria-pressed={selected}
       className={cx(
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/components/base-ui/Admin/groups/Chips.tsx` around lines 16 - 19, The
button in Chips.tsx currently only updates visuals via the selected prop; add an
accessibility attribute so assistive tech can read state by setting
aria-pressed={selected} on the <button> element (the same element using onClick
and disabled). Ensure the selected prop passed into the Chips component is a
boolean and use that boolean directly for aria-pressed to reflect
pressed/unpressed state.
src/lib/api/endpoints/Clubnotification.ts (1)

16-20: Collapse duplicate notice URL builders to one source.

Line 16 and Line 19 duplicate the same path shape as Line 8-9. Reusing noticeDetail avoids drift if the route changes later.

♻️ Suggested refactor
-  updateNotice: (clubId: number, noticeId: number) =>
-    `${API_BASE_URL}/clubs/${clubId}/notices/${noticeId}`,
+  updateNotice: (clubId: number, noticeId: number) =>
+    CLUB_NOTIFICATION.noticeDetail(clubId, noticeId),

-  deleteNotice: (clubId: number, noticeId: number) =>
-    `${API_BASE_URL}/clubs/${clubId}/notices/${noticeId}`,
+  deleteNotice: (clubId: number, noticeId: number) =>
+    CLUB_NOTIFICATION.noticeDetail(clubId, noticeId),
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/lib/api/endpoints/Clubnotification.ts` around lines 16 - 20, The
updateNotice and deleteNotice URL builders duplicate the same path as
noticeDetail; refactor both to reuse the existing noticeDetail helper to avoid
drift: replace the template strings in updateNotice and deleteNotice with calls
that return noticeDetail(clubId, noticeId) (or delegate to whatever existing
function name `noticeDetail` uses) so all three use the single source of truth
for the notice URL.
src/services/clubNotificationService.ts (1)

60-77: Use dedicated endpoint helpers for update/delete calls.

Line 61 and Line 76 currently route through noticeDetail; using updateNotice / deleteNotice keeps intent and mapping explicit.

♻️ Suggested refactor
     const res = await apiClient.patch<UpdateClubNoticeResponse>(
-      CLUB_NOTIFICATION.noticeDetail(clubId, noticeId),
+      CLUB_NOTIFICATION.updateNotice(clubId, noticeId),
       body
     );
@@
     const res = await apiClient.delete<DeleteClubNoticeResponse>(
-      CLUB_NOTIFICATION.noticeDetail(clubId, noticeId)
+      CLUB_NOTIFICATION.deleteNotice(clubId, noticeId)
     );
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/services/clubNotificationService.ts` around lines 60 - 77, The patch and
delete calls currently use CLUB_NOTIFICATION.noticeDetail(clubId, noticeId);
update them to use the dedicated endpoint helpers
CLUB_NOTIFICATION.updateNotice(clubId, noticeId) for the patch in the
updateNotice method and CLUB_NOTIFICATION.deleteNotice(clubId, noticeId) for the
delete call in deleteNotice so the intent and mapping are explicit; change the
API calls in the updateNotice and deleteNotice functions to call those helper
methods and keep the request body/response types unchanged.

ℹ️ Review info

Configuration used: defaults

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between b25bcdb and 19d36b3.

⛔ Files ignored due to path filters (4)
  • public/AdminEdit Square.svg is excluded by !**/*.svg
  • public/AdminEdit.svg is excluded by !**/*.svg
  • public/AdminUser.svg is excluded by !**/*.svg
  • public/icon_minus_2.svg is excluded by !**/*.svg
📒 Files selected for processing (28)
  • next.config.ts
  • src/app/(admin)/admin/(app)/groups/[id]/members/page.tsx
  • src/app/(admin)/admin/(app)/groups/[id]/page.tsx
  • src/app/(admin)/admin/(app)/groups/page.tsx
  • src/app/(admin)/admin/(app)/news/[id]/page.tsx
  • src/app/(admin)/admin/(app)/news/new/page.tsx
  • src/app/(admin)/admin/(app)/news/page.tsx
  • src/app/groups/[id]/admin/notice/new/page.tsx
  • src/app/groups/[id]/bookcase/page.tsx
  • src/app/groups/[id]/layout.tsx
  • src/app/groups/[id]/notice/[noticeId]/page.tsx
  • src/app/groups/[id]/notice/page.tsx
  • src/components/base-ui/Admin/groups/Category.tsx
  • src/components/base-ui/Admin/groups/Chips.tsx
  • src/components/base-ui/Admin/groups/Target.tsx
  • src/components/base-ui/Admin/groups/TopItem.tsx
  • src/components/base-ui/Bookcase/BookDetailCard.tsx
  • src/components/base-ui/Group/BookshelfModal.tsx
  • src/components/base-ui/Group/notice_item.tsx
  • src/hooks/mutations/useClubNoticeCommentMutations.ts
  • src/hooks/mutations/useClubNotificationMutations.ts
  • src/hooks/queries/useClubNoticeCommentQueries.ts
  • src/hooks/queries/useClubNotificationQueries.ts
  • src/hooks/queries/useClubsBookshelfQueries.ts
  • src/lib/api/endpoints/Clubnotification.ts
  • src/services/clubNoticeCommentService.ts
  • src/services/clubNotificationService.ts
  • src/types/clubnotification.ts

Comment on lines +15 to +23
export default function MembersListPage() {
const [page, setPage] = useState(1);

// 모임 이름 (나중에 API로 교체 가능)
const groupName = "북적북적";

// 더미 데이터
const users: UserRow[] = useMemo(() => {
const base = [
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Find the target file
target=$(fd 'page.tsx' 2>/dev/null | grep 'groups/\[id\]/members/page.tsx$' | head -n1) || {
  # Fallback: use git ls-files
  target=$(git ls-files | grep 'groups/\[id\]/members/page.tsx$' | head -n1)
}

if [ -z "$target" ]; then
  echo "File not found"
  exit 1
fi

echo "Target file: $target"
echo ""

echo "== Full file content =="
cat -n "$target"
echo ""

echo "== Route param usage (useParams/params) =="
rg -n 'useParams|params' "$target" || echo "No matches found"
echo ""

echo "== Synthetic data markers =="
rg -n 'Array\.from\(\{ length: 100 \}\)|const base =|groupName = "북적북적"|groupName = ' "$target" || echo "No matches found"

Repository: checkmo2025/FE

Length of output: 8489


Dynamic route [id] is not used; every group renders the same synthetic members.

Line 15 defines a page under groups/[id], but lines 19 and 56 build fixed local data and never bind to the route id. The component does not import or call useParams, so the group id is never read. This breaks per-group correctness in admin flows.

Proposed direction
 import { useMemo, useState } from "react";
+import { useParams } from "next/navigation";
 import Link from "next/link";

 export default function MembersListPage() {
+  const { id: groupId } = useParams<{ id: string }>();
   const [page, setPage] = useState(1);

-  const groupName = "북적북적";
+  // TODO: load group/member data by groupId from API/query layer
+  const groupName = `모임 ${groupId}`;

-  const users: UserRow[] = useMemo(() => {
-    const base = [/* ...synthetic rows... */];
-    return Array.from({ length: 100 }).map((_, i) => ({ /* ... */ }));
-  }, []);
+  const users: UserRow[] = useMemo(() => {
+    // replace with server-driven members by groupId
+    return [];
+  }, [groupId]);

Also applies to: lines 56–69

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/app/`(admin)/admin/(app)/groups/[id]/members/page.tsx around lines 15 -
23, The page component MembersListPage currently ignores the dynamic route id
and always renders the same dummy group/users; import and call useParams (from
'next/navigation') inside MembersListPage to read the route param (e.g. const {
id } = useParams()), then use that id when deriving groupName and when creating
the users memo (add id to the useMemo dependency array) or use it to fetch real
data for that group; update any hardcoded values (groupName string and the
synthetic users generator) to incorporate the id so each group route renders its
own members.

Comment on lines +24 to +53
{
name: "윤현일",
email: "yh9839@naver.com",
phone: "010-1234-5678",
role: "개설자",
},
{
name: "김민수",
email: "minsu@test.com",
phone: "010-2222-3333",
role: "운영진",
},
{
name: "박지은",
email: "jieun@test.com",
phone: "010-4444-5555",
role: "회원",
},
{
name: "이서연",
email: "seoyeon@test.com",
phone: "010-7777-8888",
role: "회원",
},
{
name: "정다은",
email: "daeun@test.com",
phone: "010-9999-0000",
role: "회원",
},
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Avoid embedding contact-like PII literals in production page code.

Line [26], Line [27], Line [32], and similar lines hardcode email/phone/name fields directly in UI source. Even as mock data, this is a compliance/privacy risk in shipped admin code.

Safer mock pattern
-      {
-        name: "윤현일",
-        email: "yh9839@naver.com",
-        phone: "010-1234-5678",
-        role: "개설자",
-      },
+      {
+        name: "회원 A",
+        email: "member-a@example.com",
+        phone: "010-0000-0000",
+        role: "개설자",
+      },
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
{
name: "윤현일",
email: "yh9839@naver.com",
phone: "010-1234-5678",
role: "개설자",
},
{
name: "김민수",
email: "minsu@test.com",
phone: "010-2222-3333",
role: "운영진",
},
{
name: "박지은",
email: "jieun@test.com",
phone: "010-4444-5555",
role: "회원",
},
{
name: "이서연",
email: "seoyeon@test.com",
phone: "010-7777-8888",
role: "회원",
},
{
name: "정다은",
email: "daeun@test.com",
phone: "010-9999-0000",
role: "회원",
},
{
name: "회원 A",
email: "member-a@example.com",
phone: "010-0000-0000",
role: "개설자",
},
{
name: "김민수",
email: "minsu@test.com",
phone: "010-2222-3333",
role: "운영진",
},
{
name: "박지은",
email: "jieun@test.com",
phone: "010-4444-5555",
role: "회원",
},
{
name: "이서연",
email: "seoyeon@test.com",
phone: "010-7777-8888",
role: "회원",
},
{
name: "정다은",
email: "daeun@test.com",
phone: "010-9999-0000",
role: "회원",
},
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/app/`(admin)/admin/(app)/groups/[id]/members/page.tsx around lines 24 -
53, The page currently embeds real-looking PII directly in the members mock
objects (name/email/phone fields); replace those literals with anonymized test
data or generate them at runtime from a mock factory. Update the members array
used in the members page to use placeholder values (e.g., "User 1",
"user1@example.test", "000-0000-0000") or call a helper like getMockMembers()
that synthesizes non-identifiable data, or move the sample data into a
test/seed-only module and import it; ensure you update the objects referenced by
the members list in the page component so no real-looking emails/phones/names
remain in production code.

Comment on lines +128 to +162
{/* 링크 */}
<div className="mt-14">
<h2 className="text-Gray-7 text-[18px] subhead_2 leading-[135%] tracking-[-0.018px] mb-3">
링크
</h2>

<div className="flex gap-4">
<input
type="text"
placeholder="링크 대체 텍스트 입력(최대 20자)"
className="flex-1 h-[56px] rounded-[8px] border border-Subbrown-4 bg-White px-4 text-[18px] outline-none"
/>
<input
type="text"
placeholder="링크 입력(최대 100자)"
className="flex-1 h-[56px] rounded-[8px] border border-Subbrown-4 bg-White px-4 text-[18px] outline-none"
/>
</div>

<div className="mt-4">
<div className="w-full h-[56px] rounded-[8px] bg-Gray-1 flex items-center justify-center text-[24px] text-Gray-5">
+
</div>
</div>

{/* 저장하기 버튼 */}
<div className="mt-20 flex justify-end">
<button
type="button"
className="w-[148px] h-[48px] rounded-[8px] bg-[var(--Primary_1)] text-White text-[16px] font-normal leading-[135%] tracking-[-0.016px]"
>
저장하기
</button>
</div>
</div>
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Save functionality is not implemented.

The "저장하기" button has no onClick handler, and the inputs are uncontrolled (no value/onChange or form action). Users clicking save will have no effect.

Since this is a Server Component, you'll need to either:

  1. Convert to a Client Component with state management, or
  2. Use a Server Action with a <form action={...}> pattern

Would you like me to help implement the save functionality using Server Actions or a Client Component approach?

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/app/`(admin)/admin/(app)/groups/[id]/page.tsx around lines 128 - 162, The
save button has no handler and the inputs are uncontrolled, so implement saving
by either converting this Server Component to a Client Component and adding
state + an onClick handler named handleSave that reads the input state and POSTs
to an API route, or keep the Server Component and implement a Server Action
named saveGroupLinks then wrap the inputs in a <form action={saveGroupLinks}>
with proper name attributes for each input (e.g., altText and url) and change
the button to type="submit" (button text "저장하기" remains); ensure unique
identifiers match the new handler names (handleSave for client approach,
saveGroupLinks for server action) and validate length constraints before
submitting.

Comment on lines +5 to +75
const DUMMY_NEWS = [
{
id: 1,
imageUrl: "/news_sample4.svg",
title: "책 읽는 한강공원",
content:
"소식내용소식내용소식내용소식내용소식내용소식내용소식내용소식내용소식내용소식내용",
date: "2025-10-09",
fullContent: `📚✨ 책읽는 한강공원이 📖

25년 하반기에 다시 돌아옵니다 🎶💃🏼🎺
반짝이는 강물과 따스한 햇살 아래,특별한 프로그램들이 여러분을 기다립니다.

자연 속에서 즐기는 여유, 모두가 함께 만드는 즐거움, 그리고 한강에서만 느낄 수 있는 특별한 순간까지! 한강에서 가족, 친구, 연인과 함께 소중한 추억을 만들어 보세요. 💐🌺🍀🌷

특색 있는 공간조성과 콘텐츠로 업그레이드 되었습니다 ♥️
기대하시라 🎺개봉박두~~~~~

✨일정✨

📅 9월 6일 부터 매주토요일~
⏰ 13:00~20:00

📍여의도 한강공원 멀티프라자
하반기 : 2025.9.6..~10.25. 매주 토요일

#캘박필수❤️

다채로운 축제가 가득한 한강, 하반기에도 책읽는 한강공원에서 만나요 💖💗💝`,
},
{
id: 2,
imageUrl: "/news_sample4.svg",
title: "책 읽는 한강공원",
content:
"소식내용소식내용소식내용소식내용소식내용소식내용소식내용소식내용소식내용소식내용",
date: "2025-10-09",
fullContent: "소식 상세 내용.",
},
{
id: 3,
imageUrl: "/news_sample4.svg",
title: "책 읽는 한강공원",
content:
"소식내용소식내용소식내용소식내용소식내용소식내용소식내용소식내용소식내용소식내용",
date: "2025-10-09",
fullContent: "소식 상세 내용.",
},
{
id: 4,
imageUrl: "/news_sample4.svg",
title: "책 읽는 한강공원",
content:
"소식내용소식내용소식내용소식내용소식내용소식내용소식내용소식내용소식내용소식내용",
date: "2025-10-09",
fullContent: "소식 상세 내용.",
},
];

function getNewsById(id: number) {
return DUMMY_NEWS.find((news) => news.id === id);
}

type Props = {
params: Promise<{ id: string }>;
};

export default async function AdminNewsDetailPage({ params }: Props) {
const { id } = await params;
const news = getNewsById(Number(id));

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Replace in-memory detail lookup with the real news source before merge.

Line [5]-[62] and Line [64]-[75] lock this route to four hardcoded items, so newly created or backend-provided news IDs will return 404 in admin detail flow.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/app/`(admin)/admin/(app)/news/[id]/page.tsx around lines 5 - 75, The
route currently uses a hardcoded DUMMY_NEWS array and the local getNewsById
helper inside AdminNewsDetailPage, which prevents real/created news items from
resolving; replace the in-memory lookup by removing DUMMY_NEWS/getNewsById usage
and call your real backend/data-layer from AdminNewsDetailPage (e.g., fetch or
an imported server helper like fetchNewsById/newsService.getById) using the
awaited id param, handle a not-found result by throwing a 404 response or
returning the existing not-found behavior, and ensure the page renders the
fetched news object instead of the dummy one.

Comment on lines +18 to +19
const url = URL.createObjectURL(file);
setter(url);
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

🏁 Script executed:

cat -n src/app/\(admin\)/admin/\(app\)/news/new/page.tsx | head -50

Repository: checkmo2025/FE

Length of output: 2027


🏁 Script executed:

# Search for all createObjectURL and revokeObjectURL usage in the codebase
rg -n 'createObjectURL|revokeObjectURL' --type tsx

Repository: checkmo2025/FE

Length of output: 85


🏁 Script executed:

# Search for revokeObjectURL usage in all files (broader search)
rg -n 'revokeObjectURL' --type-list | head -20

Repository: checkmo2025/FE

Length of output: 447


🏁 Script executed:

# Search without file type restriction
rg -n 'revokeObjectURL'

Repository: checkmo2025/FE

Length of output: 367


🏁 Script executed:

# Read the full file to check for any cleanup mechanisms
cat -n src/app/\(admin\)/admin/\(app\)/news/new/page.tsx | tail -100

Repository: checkmo2025/FE

Length of output: 3269


Revoke preview object URLs on component unmount and when selecting new images to prevent memory leaks.

Lines 18–19 create blob URLs via URL.createObjectURL(file) without cleanup. Each time a user re-selects an image, the old blob URL is retained in memory. Additionally, no cleanup happens on component unmount, causing indefinite memory retention.

💡 Suggested fix
-import { useId, useState } from "react";
+import { useEffect, useId, useState } from "react";
@@
   const [repPreview, setRepPreview] = useState<string | null>(null);
   const [extraPreview, setExtraPreview] = useState<string | null>(null);
+
+  useEffect(() => {
+    return () => {
+      if (repPreview) URL.revokeObjectURL(repPreview);
+      if (extraPreview) URL.revokeObjectURL(extraPreview);
+    };
+  }, [repPreview, extraPreview]);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/app/`(admin)/admin/(app)/news/new/page.tsx around lines 18 - 19, The
preview blob URL created with URL.createObjectURL(file) is never revoked; modify
the logic that calls URL.createObjectURL (where you call setter(url)) to revoke
any previous URL first (e.g., keep the current URL in a ref or state and call
URL.revokeObjectURL(prevUrl) before creating a new one) and also add a cleanup
on component unmount (useEffect cleanup) to revoke the last created URL;
reference the code that creates the URL (URL.createObjectURL), the setter you
call, and the file variable to locate where to add the revoke calls and the
effect cleanup.

Comment on lines +27 to +34
const isAdmin = useMemo(() => {
if (!Number.isFinite(clubId) || clubId <= 0) return false;
if (!meQuery.isSuccess) return false; // ✅ 성공한 데이터만 신뢰
const me = meQuery.data;
if (!me) return false;
if (typeof (me as any).staff === "boolean") return (me as any).staff;
return (me as any).myStatus === "STAFF";
}, [clubId, meQuery.isSuccess, meQuery.data]);
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Remove any in admin-role detection (lint is currently failing).

Line 32 and Line 33 use explicit any, which violates configured lint rules and weakens permission checks.

🛠️ Suggested fix
+type ClubMeInfo = {
+  staff?: boolean;
+  myStatus?: string;
+};

   const isAdmin = useMemo(() => {
     if (!Number.isFinite(clubId) || clubId <= 0) return false;
     if (!meQuery.isSuccess) return false; // ✅ 성공한 데이터만 신뢰
-    const me = meQuery.data;
+    const me = meQuery.data as ClubMeInfo | undefined;
     if (!me) return false;
-    if (typeof (me as any).staff === "boolean") return (me as any).staff;
-    return (me as any).myStatus === "STAFF";
+    if (typeof me.staff === "boolean") return me.staff;
+    return me.myStatus === "STAFF";
   }, [clubId, meQuery.isSuccess, meQuery.data]);
🧰 Tools
🪛 ESLint

[error] 32-32: Unexpected any. Specify a different type.

(@typescript-eslint/no-explicit-any)


[error] 32-32: Unexpected any. Specify a different type.

(@typescript-eslint/no-explicit-any)


[error] 33-33: Unexpected any. Specify a different type.

(@typescript-eslint/no-explicit-any)

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/app/groups/`[id]/notice/page.tsx around lines 27 - 34, The admin-role
detection uses explicit any for me which breaks lint; instead narrow
meQuery.data with a proper type guard or property checks and avoid any: in the
isAdmin memo, treat me as unknown and check properties via typeof and the in
operator (e.g. if (me && typeof me === "object") { if ("staff" in me && typeof
(me as any).staff === "boolean") return (me as {staff: boolean}).staff; return
"myStatus" in me && (me as {myStatus?: string}).myStatus === "STAFF"; }), or
create a Member type/interface and cast meQuery.data to Member after runtime
checks; update references to meQuery.data and remove all uses of explicit any
(methods: isAdmin, meQuery, clubId, properties staff and myStatus).

Comment on lines +15 to +33
coverImgSrc = "/group_cover_placeholder.svg",
}: Props) {
return (
<section className="w-full">
{/* 타이틀 */}
<h1 className="text-Gray-7 font-semibold text-[18px] t:text-[22px] leading-[135%]">
{name}
</h1>

{/* 커버 + 소개 */}
<div className="mt-3 flex gap-6">
<div className="relative w-[148px] h-[148px] t:w-[148px] t:h-[148px] rounded-md overflow-hidden bg-Gray-1 shrink-0">
<Image
src={coverImgSrc}
alt={`${name} 커버`}
fill
className="object-cover"
sizes="148px"
/>
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

cat -n src/components/base-ui/Admin/groups/TopItem.tsx | head -40

Repository: checkmo2025/FE

Length of output: 1455


🏁 Script executed:

rg -n --type=ts --type=tsx '<GroupTopSection\b|coverImgSrc\s*=' -C2

Repository: checkmo2025/FE

Length of output: 85


🏁 Script executed:

rg -n 'GroupTopSection' --type=ts --type=js --type=tsx --type=jsx -B2 -A2

Repository: checkmo2025/FE

Length of output: 85


🏁 Script executed:

rg -n 'coverImgSrc' --type=ts --type=js --type=tsx --type=jsx -B2 -A2

Repository: checkmo2025/FE

Length of output: 85


🏁 Script executed:

rg -n 'GroupTopSection' -B2 -A2

Repository: checkmo2025/FE

Length of output: 375


🏁 Script executed:

rg -n 'coverImgSrc' -B2 -A2

Repository: checkmo2025/FE

Length of output: 12203


Normalize coverImgSrc to prevent empty strings from reaching <Image>.

The current default handles undefined but not empty strings. At the call-site (src/app/(admin)/admin/(app)/groups/[id]/page.tsx:93), group.coverImgSrc ?? undefined converts null to undefined but does not guard against empty strings from the API. Other similar components in the codebase (bookstory_card.tsx, bookstory_card_large.tsx, AdminBookStoryCard.tsx) use conditional rendering {coverImgSrc && ...} to prevent empty values from reaching the Image component. GroupTopSection should apply the same safeguard.

Proposed fix
 export default function GroupTopSection({
   name,
   intro,
-  coverImgSrc = "/group_cover_placeholder.svg",
+  coverImgSrc,
 }: Props) {
+  const resolvedCoverImgSrc =
+    typeof coverImgSrc === "string" && coverImgSrc.trim().length > 0
+      ? coverImgSrc
+      : "/group_cover_placeholder.svg";
+
   return (
@@
           <Image
-            src={coverImgSrc}
+            src={resolvedCoverImgSrc}
             alt={`${name} 커버`}
             fill
             className="object-cover"
             sizes="148px"
           />
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/components/base-ui/Admin/groups/TopItem.tsx` around lines 15 - 33,
TopItem currently passes coverImgSrc directly into the Image component allowing
empty strings; normalize or guard it first so empty values don't reach Image.
Update the TopItem component to compute a safe value (e.g. const safeCover =
coverImgSrc && coverImgSrc.trim() ? coverImgSrc :
"/group_cover_placeholder.svg") or wrap the Image render with conditional
rendering ({safeCover && <Image ... src={safeCover} ... />}), referencing the
coverImgSrc prop and the Image usage inside TopItem to mirror the pattern used
in bookstory_card.tsx and AdminBookStoryCard.tsx.

Comment on lines +130 to +139
<BookcaseCard
title={book.title}
author={book.author}
category={book.category}
rating={book.rating}
onTopicClick={() => {}}
onReviewClick={() => {}}
onMeetingClick={() => {}}
imageUrl={book.imageUrl ?? ""}
/>
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

🏁 Script executed:

# Find BookcaseCard component location
fd -t f 'BookcaseCard' --extension ts --extension tsx

Repository: checkmo2025/FE

Length of output: 106


🏁 Script executed:

# Search for BookcaseCard implementation and how it uses imageUrl
rg -n 'BookcaseCard' --glob '**/*.{ts,tsx}' -A 2 -B 2 | head -50

Repository: checkmo2025/FE

Length of output: 3266


🏁 Script executed:

# Look for the actual BookcaseCard component definition and imageUrl prop usage
rg -n 'imageUrl' --glob '**/BookcaseCard*' -A 3 -B 1

Repository: checkmo2025/FE

Length of output: 1155


Pass a valid fallback image URL instead of an empty string.

When book.imageUrl is null or undefined, an empty string is currently passed to BookcaseCard (line 138). This bypasses the component's default dummy image and causes next/image to render with an invalid empty src attribute.

Change the fallback to the dummy image path:

imageUrl={book.imageUrl ?? "/dummy_book_cover.png"}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/components/base-ui/Group/BookshelfModal.tsx` around lines 130 - 139, The
BookcaseCard is receiving an empty string when book.imageUrl is null/undefined
which produces an invalid next/image src; update the imageUrl prop passed to
BookcaseCard (the imageUrl prop in the JSX where BookcaseCard is rendered) to
use a real fallback, e.g. change the fallback from an empty string to the dummy
image path so it becomes imageUrl={book.imageUrl ?? "/dummy_book_cover.png"}
ensuring the component/default image behavior and next/image get a valid src.

Comment on lines 29 to +33
<div
onClick={onClick}
className={`
w-full t:w-full d:w-[1039px] h-[48px] border border-Subbrown-3 rounded-lg cursor-pointer
${isPinned ? 'bg-Subbrown-4' : 'bg-white'}
w-full t:w-full d:w-[1039px] h-[48px] border border-Subbrown-3 rounded-lg cursor-pointer hover:brightness-97
${isPinned ? "bg-Subbrown-4" : "bg-white"}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Make row interaction keyboard-accessible and conditional.

onClick is optional, but pointer/hover affordance is always enabled; plus the clickable div is not keyboard-accessible.

♿ Proposed fix
-    <div
-      onClick={onClick}
+    <div
+      onClick={onClick}
+      role={onClick ? "button" : undefined}
+      tabIndex={onClick ? 0 : undefined}
+      onKeyDown={
+        onClick
+          ? (e) => {
+              if (e.key === "Enter" || e.key === " ") {
+                e.preventDefault();
+                onClick();
+              }
+            }
+          : undefined
+      }
       className={`
-        w-full t:w-full d:w-[1039px] h-[48px] border border-Subbrown-3 rounded-lg cursor-pointer hover:brightness-97   
+        w-full t:w-full d:w-[1039px] h-[48px] border border-Subbrown-3 rounded-lg
+        ${onClick ? "cursor-pointer hover:brightness-97" : ""}
         ${isPinned ? "bg-Subbrown-4" : "bg-white"}
         ${className}
       `}
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
<div
onClick={onClick}
className={`
w-full t:w-full d:w-[1039px] h-[48px] border border-Subbrown-3 rounded-lg cursor-pointer
${isPinned ? 'bg-Subbrown-4' : 'bg-white'}
w-full t:w-full d:w-[1039px] h-[48px] border border-Subbrown-3 rounded-lg cursor-pointer hover:brightness-97
${isPinned ? "bg-Subbrown-4" : "bg-white"}
<div
onClick={onClick}
role={onClick ? "button" : undefined}
tabIndex={onClick ? 0 : undefined}
onKeyDown={
onClick
? (e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
onClick();
}
}
: undefined
}
className={`
w-full t:w-full d:w-[1039px] h-[48px] border border-Subbrown-3 rounded-lg
${onClick ? "cursor-pointer hover:brightness-97" : ""}
${isPinned ? "bg-Subbrown-4" : "bg-white"}
${className}
`}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/components/base-ui/Group/notice_item.tsx` around lines 29 - 33, The row
currently always shows pointer/hover affordances and uses a plain div with an
optional onClick, making it non-keyboard-accessible and misleading; update the
interactive element in notice_item.tsx so that if onClick is provided the
container becomes a semantic, focusable interactive control (preferably a
<button> or a div with role="button", tabIndex=0, and onKeyDown handling
Enter/Space to call onClick) and apply cursor-pointer/hover styles only when
onClick is defined; when onClick is absent keep it non-interactive (no
role/tabIndex/cursor-pointer/hover) and preserve the isPinned conditional
background logic.

Comment on lines +30 to +43
export function useClubsBookshelfSimpleInfiniteQuery(clubId: number, p0: { enabled: boolean; }) {
return useInfiniteQuery({
queryKey: bookshelfQueryKeys.simple(clubId),
initialPageParam: undefined as number | undefined,
queryFn: ({ pageParam }) =>
clubsBookshelfService.getSimpleBookshelves({
clubId,
cursorId: pageParam,
}),
getNextPageParam: (lastPage) =>
lastPage.hasNext ? lastPage.nextCursor ?? undefined : undefined,
enabled: Number.isFinite(clubId),
});
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Parameter p0 is declared but never used.

The second parameter p0: { enabled: boolean; } is completely ignored—the hook hardcodes enabled: Number.isFinite(clubId) on line 41. This causes the caller's enabled option to have no effect.

Proposed fix
-export function useClubsBookshelfSimpleInfiniteQuery(clubId: number, p0: { enabled: boolean; }) {
+export function useClubsBookshelfSimpleInfiniteQuery(
+  clubId: number,
+  options?: { enabled?: boolean }
+) {
   return useInfiniteQuery({
     queryKey: bookshelfQueryKeys.simple(clubId),
     initialPageParam: undefined as number | undefined,
     queryFn: ({ pageParam }) =>
       clubsBookshelfService.getSimpleBookshelves({
         clubId,
         cursorId: pageParam,
       }),
     getNextPageParam: (lastPage) =>
       lastPage.hasNext ? lastPage.nextCursor ?? undefined : undefined,
-    enabled: Number.isFinite(clubId),
+    enabled: (options?.enabled ?? true) && Number.isFinite(clubId),
   });
 }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
export function useClubsBookshelfSimpleInfiniteQuery(clubId: number, p0: { enabled: boolean; }) {
return useInfiniteQuery({
queryKey: bookshelfQueryKeys.simple(clubId),
initialPageParam: undefined as number | undefined,
queryFn: ({ pageParam }) =>
clubsBookshelfService.getSimpleBookshelves({
clubId,
cursorId: pageParam,
}),
getNextPageParam: (lastPage) =>
lastPage.hasNext ? lastPage.nextCursor ?? undefined : undefined,
enabled: Number.isFinite(clubId),
});
}
export function useClubsBookshelfSimpleInfiniteQuery(
clubId: number,
options?: { enabled?: boolean }
) {
return useInfiniteQuery({
queryKey: bookshelfQueryKeys.simple(clubId),
initialPageParam: undefined as number | undefined,
queryFn: ({ pageParam }) =>
clubsBookshelfService.getSimpleBookshelves({
clubId,
cursorId: pageParam,
}),
getNextPageParam: (lastPage) =>
lastPage.hasNext ? lastPage.nextCursor ?? undefined : undefined,
enabled: (options?.enabled ?? true) && Number.isFinite(clubId),
});
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/hooks/queries/useClubsBookshelfQueries.ts` around lines 30 - 43, The hook
useClubsBookshelfSimpleInfiniteQuery currently declares an unused second
parameter p0; change this to an options parameter (e.g., options?: { enabled?:
boolean }) and use options.enabled in the query config instead of hardcoding
enabled: Number.isFinite(clubId) so callers can control the enabled flag;
implement it as enabled: options?.enabled ?? Number.isFinite(clubId) while
keeping the rest of the function (queryKey, queryFn, getNextPageParam) intact to
preserve existing behavior.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 8

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
src/components/base-ui/BookStory/bookstory_detail.tsx (1)

156-156: ⚠️ Potential issue | 🟠 Major

Same menuRef assigned to two different elements.

The menuRef is assigned to both the mobile hamburger menu (Line 156) and the desktop hamburger menu (Line 317). In React, a ref can only point to one DOM element at a time—whichever renders last will hold the ref. This breaks the click-outside detection for one of the menus.

Consider using separate refs for mobile and desktop menus.

🐛 Proposed fix
   const [menuOpen, setMenuOpen] = useState(false);
   const [isReportModalOpen, setIsReportModalOpen] = useState(false);
-  const menuRef = useRef<HTMLDivElement>(null);
+  const mobileMenuRef = useRef<HTMLDivElement>(null);
+  const desktopMenuRef = useRef<HTMLDivElement>(null);

   // 바깥 클릭 시 메뉴 닫기
   useEffect(() => {
     const handleClickOutside = (e: MouseEvent) => {
-      if (menuRef.current && !menuRef.current.contains(e.target as Node)) {
+      const target = e.target as Node;
+      const isOutsideMobile = mobileMenuRef.current && !mobileMenuRef.current.contains(target);
+      const isOutsideDesktop = desktopMenuRef.current && !desktopMenuRef.current.contains(target);
+      if (isOutsideMobile && isOutsideDesktop) {
         setMenuOpen(false);
       }
     };

Then update the refs in JSX:

  • Line 156: ref={mobileMenuRef}
  • Line 317: ref={desktopMenuRef}

Also applies to: 317-317

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/components/base-ui/BookStory/bookstory_detail.tsx` at line 156, menuRef
is being reused for both mobile and desktop hamburger menus which causes one ref
to overwrite the other and breaks click-outside detection; create two separate
refs (e.g., mobileMenuRef and desktopMenuRef using React.useRef) instead of
reusing menuRef, replace ref={menuRef} with ref={mobileMenuRef} on the mobile
menu element and ref={desktopMenuRef} on the desktop menu element, and update
any click-outside handlers or useEffect logic (handlers that reference menuRef
or functions like handleClickOutside) to check both mobileMenuRef.current and
desktopMenuRef.current (or register/unregister both) so each menu’s
outside-click detection works independently.
🧹 Nitpick comments (9)
src/app/(main)/notification/page.tsx (1)

1-1: Consider removing unnecessary "use client" directive.

This component has no client-side interactivity—no hooks (useState, useEffect), event handlers, or browser APIs. Next.js Image and Link work in Server Components. Removing the directive reduces the client bundle.

♻️ Suggested change
-"use client";
-
 import Image from "next/image";
 import Link from "next/link";
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/app/`(main)/notification/page.tsx at line 1, Remove the top-level "use
client" directive from the notification page component (page.tsx) because it
contains no client-side interactivity; ensure there are no React client hooks
(useState/useEffect), event handlers, or browser-only APIs imported in that file
and remove any client-only imports if present so the component can be a Server
Component; if you deliberately need client behavior, keep "use client" and add a
comment explaining why.
src/app/groups/page.tsx (1)

92-101: Consider a cleaner approach for delayed modal opening.

The setTimeout with a magic number delay is fragile and may lead to race conditions. Consider using a callback or event-based approach instead, or at minimum extract the delay constant.

♻️ Optional: Extract the delay constant
+const LOGIN_MODAL_DELAY_MS = 100;
+
 useEffect(() => {
   if (isInitialized && !isLoggedIn) {
     toast.error("모임은 로그인이 필요한 서비스입니다.", { id: "groups-auth-error" });
     router.replace("/");
-    // 약간의 지연을 주어 홈으로 이동한 후 모달이 뜨게 함
     setTimeout(() => {
       openLoginModal();
-    }, 100);
+    }, LOGIN_MODAL_DELAY_MS);
   }
 }, [isLoggedIn, isInitialized, router, openLoginModal]);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/app/groups/page.tsx` around lines 92 - 101, The current useEffect uses a
fragile setTimeout to open the login modal after router.replace; change it to
await the navigation promise instead of relying on a magic delay: inside the
useEffect create and call an async function that does router.replace("/") and
then calls openLoginModal() (ensuring you await router.replace since it returns
a Promise), or if you must keep a delay, extract the numeric delay into a named
constant (e.g., LOGIN_MODAL_OPEN_DELAY) and use that instead of the magic 100;
update references in useEffect (isInitialized, isLoggedIn, router.replace,
openLoginModal, setTimeout) accordingly.
src/app/(main)/stories/new/page.tsx (1)

17-17: Use Zustand selectors to prevent unnecessary rerenders.

This component subscribes to the entire auth store via useAuthStore() without selectors. Any change to unrelated fields (e.g., user, isLoginModalOpen) will rerender this page. Extract only the required fields:

♻️ Proposed refactor
-  const { isLoggedIn, isInitialized, openLoginModal } = useAuthStore();
+  const isLoggedIn = useAuthStore((state) => state.isLoggedIn);
+  const isInitialized = useAuthStore((state) => state.isInitialized);
+  const openLoginModal = useAuthStore((state) => state.openLoginModal);

Note: This pattern is used throughout the codebase and would benefit from coordinated refactoring across multiple files.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/app/`(main)/stories/new/page.tsx at line 17, The component currently
calls useAuthStore() without selectors, causing it to subscribe to the whole
auth store and rerender on unrelated changes; change the call to
useAuthStore(selector) and select only isLoggedIn, isInitialized, and
openLoginModal (e.g., useAuthStore(s => ({ isLoggedIn: s.isLoggedIn,
isInitialized: s.isInitialized, openLoginModal: s.openLoginModal }))) so the
component only rerenders when those specific fields change; update any other
similar usages in this file to use selector form for performance consistency.
src/components/base-ui/Comment/comment_section.tsx (2)

86-88: Avoid setState directly in useEffect to prevent cascading renders.

The ESLint rule react-hooks/set-state-in-effect flags this pattern. Since comments is derived from initialComments, consider using useMemo or deriving the value inline to avoid the extra render cycle.

♻️ Proposed refactor using useMemo
-  const [comments, setComments] = useState<Comment[]>(() => mapApiToUiComments(initialComments));
+  const comments = useMemo(
+    () => mapApiToUiComments(initialComments),
+    [initialComments, storyAuthorNickname]
+  );

-  // 데이터가 변경되면 상태 업데이트
-  useEffect(() => {
-    setComments(mapApiToUiComments(initialComments));
-  }, [initialComments, storyAuthorNickname]);

Note: Ensure mapApiToUiComments is memoized or defined outside the component to avoid recreating it on every render.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/components/base-ui/Comment/comment_section.tsx` around lines 86 - 88, The
useEffect currently calls setComments(mapApiToUiComments(initialComments))
causing an unnecessary render; replace this derived state with a memoized value
(e.g., compute comments = useMemo(() => mapApiToUiComments(initialComments),
[initialComments, storyAuthorNickname])) or derive it inline so you remove the
setComments call and the useEffect entirely; make sure mapApiToUiComments is
memoized or defined outside the component to avoid recreating it on every
render.

191-204: Report type mapping could use a constant map for maintainability.

The string-to-enum mapping uses multiple if statements. Consider using a lookup object for cleaner code and easier maintenance.

♻️ Proposed refactor
+const REPORT_TYPE_MAP: Record<string, ReportType> = {
+  "책 이야기": "BOOK_STORY",
+  "책이야기(댓글)": "COMMENT",
+  "책모임 내부": "CLUB_MEETING",
+};

 const handleReportSubmit = (type: string, content: string) => {
-  let mappedType: ReportType = "GENERAL";
-  if (type === "책 이야기") mappedType = "BOOK_STORY";
-  if (type === "책이야기(댓글)") mappedType = "COMMENT";
-  if (type === "책모임 내부") mappedType = "CLUB_MEETING";
+  const mappedType: ReportType = REPORT_TYPE_MAP[type] ?? "GENERAL";

   if (reportTargetNickname) {
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/components/base-ui/Comment/comment_section.tsx` around lines 191 - 204,
Replace the repeated if-statements in handleReportSubmit with a constant lookup
map from the incoming string keys to ReportType values (e.g., const
REPORT_TYPE_MAP = { "책 이야기": "BOOK_STORY", "책이야기(댓글)": "COMMENT", "책모임 내부":
"CLUB_MEETING" } as const), then derive mappedType = REPORT_TYPE_MAP[type] ??
"GENERAL"; keep the existing usage of reportTargetNickname and reportMember({
reportedMemberNickname: reportTargetNickname, reportType: mappedType, content
}); and ensure the map and type assertion reference the ReportType union to keep
typings correct.
src/components/layout/Header.tsx (1)

38-44: Navigation bypasses Next.js <Link> prefetching for all items.

When onClick is passed to NavItem, it renders a <div> and calls router.push(href) for navigation. This applies to all nav items, not just the gated "모임" tab, which means you lose Next.js <Link> prefetching benefits for the other tabs.

Consider only passing onClick for gated items, or modifying NavItem to use <Link> with an onClick that can preventDefault() when needed (similar to BottomNav.tsx).

♻️ Alternative: only gate specific items
   return (
     <NavItem
       key={item.href}
       href={item.href}
       label={item.label}
       active={active}
-      onClick={() => handleNavClick(item.href, item.label)}
+      onClick={
+        item.label === "모임"
+          ? () => handleNavClick(item.href, item.label)
+          : undefined
+      }
     />
   );

Also applies to: 78-78

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/components/layout/Header.tsx` around lines 38 - 44, The nav currently
bypasses Next.js Link prefetching because handleNavClick is passed to NavItem
for all items and calls router.push(href); update this so only gated items use
onClick (keep NavItem rendering a Link for normal items) — either (A) change the
call sites so handleNavClick is only passed when label === "모임" (keep
router.push logic there to openLoginModal when not logged in), or (B) refactor
NavItem to render a Next.js <Link> and accept an optional onClick that calls
event.preventDefault() when it needs to gate navigation (follow the pattern in
BottomNav.tsx) so prefetching remains for non-gated tabs; references:
handleNavClick, NavItem, BottomNav.tsx, router.push, and the gated label "모임".
src/components/base-ui/BookStory/bookstory_detail.tsx (1)

94-105: Duplicate report type mapping logic.

The handleReportSubmit function duplicates the same string-to-ReportType mapping found in comment_section.tsx. Consider extracting this to a shared utility function to maintain consistency and reduce duplication.

♻️ Suggested shared utility

Create a shared mapping function:

// src/utils/reportUtils.ts
import { ReportType } from "@/types/member";

const REPORT_TYPE_MAP: Record<string, ReportType> = {
  "일반": "GENERAL",
  "책 이야기": "BOOK_STORY",
  "책이야기(댓글)": "COMMENT",
  "책모임 내부": "CLUB_MEETING",
};

export const mapReportType = (uiType: string): ReportType =>
  REPORT_TYPE_MAP[uiType] ?? "GENERAL";

Then use it in both files:

+import { mapReportType } from "@/utils/reportUtils";

 const handleReportSubmit = (type: string, content: string) => {
-  let mappedType: ReportType = "GENERAL";
-  if (type === "책 이야기") mappedType = "BOOK_STORY";
-  if (type === "책이야기(댓글)") mappedType = "COMMENT";
-  if (type === "책모임 내부") mappedType = "CLUB_MEETING";
+  const mappedType = mapReportType(type);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/components/base-ui/BookStory/bookstory_detail.tsx` around lines 94 - 105,
The report type mapping in handleReportSubmit is duplicated elsewhere; extract
the mapping into a shared utility (e.g., create REPORT_TYPE_MAP and
mapReportType(uiType: string): ReportType) and replace the inline mapping in
handleReportSubmit with a call to mapReportType(type); also update the other
caller (the mapping in comment_section.tsx) to use the same mapReportType to
ensure consistency and remove duplication.
src/app/(main)/stories/page.tsx (1)

187-213: Extract a shared story-card renderer to remove duplicated JSX.

Both map blocks pass the same BookStoryCardLarge props. Consolidating this into one renderer will reduce drift risk and simplify future prop changes.

Refactor sketch
+ const renderStoryCard = (story: (typeof allStories)[number]) => (
+   <div key={story.bookStoryId} className="shrink-0">
+     <BookStoryCardLarge
+       id={story.bookStoryId}
+       authorName={story.authorInfo.nickname}
+       profileImgSrc={story.authorInfo.profileImageUrl}
+       createdAt={story.createdAt}
+       viewCount={story.viewCount}
+       title={story.bookStoryTitle}
+       content={story.description}
+       likeCount={story.likes}
+       commentCount={story.commentCount}
+       likedByMe={story.likedByMe}
+       coverImgSrc={story.bookInfo.imgUrl}
+       subscribeText={story.authorInfo.following ? "구독 중" : "구독"}
+       isFollowing={story.authorInfo.following}
+       onSubscribeClick={() => handleToggleFollow(story.authorInfo.nickname, story.authorInfo.following)}
+       hideSubscribeButton={story.writtenByMe}
+       onProfileClick={() => router.push(`/profile/${story.authorInfo.nickname}`)}
+       onClick={() => handleCardClick(story.bookStoryId)}
+       onLikeClick={() => handleToggleLike(story.bookStoryId)}
+     />
+   </div>
+ );

- {allStories.slice(0, 4).map((story) => ( ... ))}
+ {allStories.slice(0, 4).map(renderStoryCard)}

- {allStories.slice(4).map((story) => ( ... ))}
+ {allStories.slice(4).map(renderStoryCard)}

Also applies to: 225-251

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/app/`(main)/stories/page.tsx around lines 187 - 213, Extract a shared
renderer function (e.g., renderStoryCard or StoryCardRenderer) that accepts a
story and returns the BookStoryCardLarge JSX with the same prop mapping
currently duplicated (id -> story.bookStoryId, authorName ->
story.authorInfo.nickname, profileImgSrc -> story.authorInfo.profileImageUrl,
createdAt, viewCount, title, content, likeCount, commentCount, likedByMe,
coverImgSrc -> story.bookInfo.imgUrl, subscribeText/isFollowing ->
story.authorInfo.following, hideSubscribeButton -> story.writtenByMe, and
handlers using handleToggleFollow, handleCardClick, handleToggleLike and
router.push for profile navigation); then replace both map blocks (the one using
allStories.slice(0,4).map and the similar block at lines mentioned in the
review) with .map(renderStoryCard) (or .slice(...).map(renderStoryCard)) so that
the single renderer is reused and props remain consistent.
src/components/base-ui/Profile/OtherUser/ProfileUserInfo.tsx (1)

89-94: Centralize report-type mapping to avoid silent mismatches.

Current chained if mapping is easy to drift when labels change. A single map keeps this deterministic and maintainable.

♻️ Suggested refactor
-  const handleReportSubmit = (type: string, content: string) => {
-    let mappedType: ReportType = "GENERAL";
-    if (type === "책 이야기") mappedType = "BOOK_STORY";
-    if (type === "책이야기(댓글)") mappedType = "COMMENT";
-    if (type === "책모임 내부") mappedType = "CLUB_MEETING";
+  const REPORT_TYPE_MAP: Record<string, ReportType> = {
+    일반: "GENERAL",
+    "책 이야기": "BOOK_STORY",
+    "책이야기(댓글)": "COMMENT",
+    "책모임 내부": "CLUB_MEETING",
+  };
+
+  const handleReportSubmit = (type: string, content: string) => {
+    const mappedType = REPORT_TYPE_MAP[type] ?? "GENERAL";
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/components/base-ui/Profile/OtherUser/ProfileUserInfo.tsx` around lines 89
- 94, The chained ifs in handleReportSubmit that set mappedType (using
ReportType) are brittle and can drift; replace them with a single centralized
mapping object (e.g., const REPORT_TYPE_MAP: Record<string, ReportType>) and
look up the incoming type string against that map, falling back to "GENERAL" if
not found; update handleReportSubmit to use REPORT_TYPE_MAP[type] ?? "GENERAL"
and ensure the mapping contains the keys "책 이야기", "책이야기(댓글)", "책모임 내부" mapped to
"BOOK_STORY", "COMMENT", "CLUB_MEETING" respectively so all translations are
deterministic and maintainable.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@src/app/`(main)/stories/new/page.tsx:
- Around line 23-25: Replace the race-prone setTimeout wrapper around
openLoginModal with a deterministic flow: remove setTimeout(...) and invoke
openLoginModal() directly when the redirect condition is hit, then perform the
redirect only after the modal action resolves (either by awaiting
openLoginModal() if it returns a promise or by moving the redirect into the
modal's onClose/confirmation callback). Ensure you remove the hard-coded 100ms
delay and wire the redirect to the modal's resolution so the sequence is
deterministic.

In `@src/app/`(main)/stories/page.tsx:
- Around line 58-100: The current useMemo only switches between "following" and
default feeds, so club tabs (where selectedCategory holds a club ID) still
render the default feed; update the useMemo to branch for three cases: when
selectedCategory === "following" use followingStoriesData /
isLoadingFollowingStories / isErrorFollowingStories / fetchNextFollowingPage /
hasNextFollowingPage / isFetchingNextFollowingPage; when selectedCategory
represents a club (e.g., selectedCategory !== "following" && selectedCategory
!== "default" or explicitly check a club id flag) use the club feed variables
(clubStoriesData, isLoadingClubStories, isErrorClubStories, fetchNextClubPage,
hasNextClubPage, isFetchingNextClubPage); otherwise fall back to
defaultStoriesData and its related flags (defaultStoriesData,
isLoadingDefaultStories, etc.); also add the club-related variables and
selectedCategory into the useMemo dependency array so the memo updates when the
active club tab changes.

In `@src/app/groups/page.tsx`:
- Around line 184-186: In the catch block that currently reads "catch (e: any)"
replace the any with unknown and narrow the type before using message: change to
"catch (e: unknown)" then check e with a type guard (e.g., "if (e instanceof
Error)") to call toast.error(e.message), otherwise call toast.error(String(e) ||
"가입 신청에 실패했습니다."); update the catch in the same function / block where
toast.error is invoked so the code is type-safe.
- Around line 189-204: The onSubmitSearch handler is calling refetchSearch()
after setAppliedParams(), which is redundant because useInfiniteClubSearchQuery
uses appliedParams in its query key and will refetch automatically; remove the
explicit refetchSearch() invocation from onSubmitSearch (leave the
setAppliedParams(...) and the early setAppliedParams(null) path intact) so
updates go through React state and React Query without risking a race with stale
params.

In `@src/components/base-ui/home/home_bookclub.tsx`:
- Around line 16-36: The auth checks in handleCreateGroup and handleSearchGroup
use !isLoggedIn too early and can misfire before auth is hydrated; modify usage
of useAuthStore to also read isInitialized and centralize the logic by adding a
helper (e.g., ensureAuthenticatedOrOpenLogin) that first checks isInitialized
and then isLoggedIn, calling openLoginModal only when initialized &&
!isLoggedIn, and use that helper inside handleCreateGroup and handleSearchGroup
to remove duplication and prevent pre-initialization false negatives.

In `@src/components/base-ui/Profile/OtherUser/ProfileUserInfo.tsx`:
- Around line 140-143: OtherProfileResponse is missing follower/following counts
so ProfileUserInfo shows hardcoded 0s; add followersCount: number and
followingCount: number to the OtherProfileResponse interface in
src/types/member.ts, then update the StatItem calls in ProfileUserInfo (use the
existing profile variable) to pass profile.followingCount for the "구독 중" item
and profile.followersCount for the "구독자" item so the component renders real
stats.
- Around line 58-59: Remove the redundant decodeURIComponent call around the
route param: use the raw nickname param directly when calling
useOtherProfileQuery (replace decodedNickname with nickname) because Next.js App
Router already decodes params; locate the decodedNickname const and the
useOtherProfileQuery(decodedNickname) usage in ProfileUserInfo and remove the
decodeURIComponent invocation (if you want extra safety, optionally wrap a
defensive decode in a try/catch around a separate variable, but prefer using
nickname as-is).

In `@src/components/layout/NavItem.tsx`:
- Around line 27-33: The clickable div in NavItem.tsx (when onClick is provided)
is not keyboard-accessible; update the NavItem component to render either a
semantic <button> styled with commonClassName or, if you must keep a div, add
tabIndex={0}, role="button", and an onKeyDown handler that calls the same
onClick for Enter/Space keys so keyboard users can focus and activate the item;
ensure the handler lives alongside the existing onClick and preserves any a11y
attributes (e.g., aria-pressed if relevant) and that content is still rendered
as before.

---

Outside diff comments:
In `@src/components/base-ui/BookStory/bookstory_detail.tsx`:
- Line 156: menuRef is being reused for both mobile and desktop hamburger menus
which causes one ref to overwrite the other and breaks click-outside detection;
create two separate refs (e.g., mobileMenuRef and desktopMenuRef using
React.useRef) instead of reusing menuRef, replace ref={menuRef} with
ref={mobileMenuRef} on the mobile menu element and ref={desktopMenuRef} on the
desktop menu element, and update any click-outside handlers or useEffect logic
(handlers that reference menuRef or functions like handleClickOutside) to check
both mobileMenuRef.current and desktopMenuRef.current (or register/unregister
both) so each menu’s outside-click detection works independently.

---

Nitpick comments:
In `@src/app/`(main)/notification/page.tsx:
- Line 1: Remove the top-level "use client" directive from the notification page
component (page.tsx) because it contains no client-side interactivity; ensure
there are no React client hooks (useState/useEffect), event handlers, or
browser-only APIs imported in that file and remove any client-only imports if
present so the component can be a Server Component; if you deliberately need
client behavior, keep "use client" and add a comment explaining why.

In `@src/app/`(main)/stories/new/page.tsx:
- Line 17: The component currently calls useAuthStore() without selectors,
causing it to subscribe to the whole auth store and rerender on unrelated
changes; change the call to useAuthStore(selector) and select only isLoggedIn,
isInitialized, and openLoginModal (e.g., useAuthStore(s => ({ isLoggedIn:
s.isLoggedIn, isInitialized: s.isInitialized, openLoginModal: s.openLoginModal
}))) so the component only rerenders when those specific fields change; update
any other similar usages in this file to use selector form for performance
consistency.

In `@src/app/`(main)/stories/page.tsx:
- Around line 187-213: Extract a shared renderer function (e.g., renderStoryCard
or StoryCardRenderer) that accepts a story and returns the BookStoryCardLarge
JSX with the same prop mapping currently duplicated (id -> story.bookStoryId,
authorName -> story.authorInfo.nickname, profileImgSrc ->
story.authorInfo.profileImageUrl, createdAt, viewCount, title, content,
likeCount, commentCount, likedByMe, coverImgSrc -> story.bookInfo.imgUrl,
subscribeText/isFollowing -> story.authorInfo.following, hideSubscribeButton ->
story.writtenByMe, and handlers using handleToggleFollow, handleCardClick,
handleToggleLike and router.push for profile navigation); then replace both map
blocks (the one using allStories.slice(0,4).map and the similar block at lines
mentioned in the review) with .map(renderStoryCard) (or
.slice(...).map(renderStoryCard)) so that the single renderer is reused and
props remain consistent.

In `@src/app/groups/page.tsx`:
- Around line 92-101: The current useEffect uses a fragile setTimeout to open
the login modal after router.replace; change it to await the navigation promise
instead of relying on a magic delay: inside the useEffect create and call an
async function that does router.replace("/") and then calls openLoginModal()
(ensuring you await router.replace since it returns a Promise), or if you must
keep a delay, extract the numeric delay into a named constant (e.g.,
LOGIN_MODAL_OPEN_DELAY) and use that instead of the magic 100; update references
in useEffect (isInitialized, isLoggedIn, router.replace, openLoginModal,
setTimeout) accordingly.

In `@src/components/base-ui/BookStory/bookstory_detail.tsx`:
- Around line 94-105: The report type mapping in handleReportSubmit is
duplicated elsewhere; extract the mapping into a shared utility (e.g., create
REPORT_TYPE_MAP and mapReportType(uiType: string): ReportType) and replace the
inline mapping in handleReportSubmit with a call to mapReportType(type); also
update the other caller (the mapping in comment_section.tsx) to use the same
mapReportType to ensure consistency and remove duplication.

In `@src/components/base-ui/Comment/comment_section.tsx`:
- Around line 86-88: The useEffect currently calls
setComments(mapApiToUiComments(initialComments)) causing an unnecessary render;
replace this derived state with a memoized value (e.g., compute comments =
useMemo(() => mapApiToUiComments(initialComments), [initialComments,
storyAuthorNickname])) or derive it inline so you remove the setComments call
and the useEffect entirely; make sure mapApiToUiComments is memoized or defined
outside the component to avoid recreating it on every render.
- Around line 191-204: Replace the repeated if-statements in handleReportSubmit
with a constant lookup map from the incoming string keys to ReportType values
(e.g., const REPORT_TYPE_MAP = { "책 이야기": "BOOK_STORY", "책이야기(댓글)": "COMMENT",
"책모임 내부": "CLUB_MEETING" } as const), then derive mappedType =
REPORT_TYPE_MAP[type] ?? "GENERAL"; keep the existing usage of
reportTargetNickname and reportMember({ reportedMemberNickname:
reportTargetNickname, reportType: mappedType, content }); and ensure the map and
type assertion reference the ReportType union to keep typings correct.

In `@src/components/base-ui/Profile/OtherUser/ProfileUserInfo.tsx`:
- Around line 89-94: The chained ifs in handleReportSubmit that set mappedType
(using ReportType) are brittle and can drift; replace them with a single
centralized mapping object (e.g., const REPORT_TYPE_MAP: Record<string,
ReportType>) and look up the incoming type string against that map, falling back
to "GENERAL" if not found; update handleReportSubmit to use
REPORT_TYPE_MAP[type] ?? "GENERAL" and ensure the mapping contains the keys "책
이야기", "책이야기(댓글)", "책모임 내부" mapped to "BOOK_STORY", "COMMENT", "CLUB_MEETING"
respectively so all translations are deterministic and maintainable.

In `@src/components/layout/Header.tsx`:
- Around line 38-44: The nav currently bypasses Next.js Link prefetching because
handleNavClick is passed to NavItem for all items and calls router.push(href);
update this so only gated items use onClick (keep NavItem rendering a Link for
normal items) — either (A) change the call sites so handleNavClick is only
passed when label === "모임" (keep router.push logic there to openLoginModal when
not logged in), or (B) refactor NavItem to render a Next.js <Link> and accept an
optional onClick that calls event.preventDefault() when it needs to gate
navigation (follow the pattern in BottomNav.tsx) so prefetching remains for
non-gated tabs; references: handleNavClick, NavItem, BottomNav.tsx, router.push,
and the gated label "모임".

ℹ️ Review info

Configuration used: defaults

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 19d36b3 and 0de1126.

📒 Files selected for processing (19)
  • src/app/(main)/notification/page.tsx
  • src/app/(main)/page.tsx
  • src/app/(main)/stories/[id]/page.tsx
  • src/app/(main)/stories/new/page.tsx
  • src/app/(main)/stories/page.tsx
  • src/app/groups/page.tsx
  • src/components/base-ui/BookStory/bookstory_detail.tsx
  • src/components/base-ui/Comment/comment_section.tsx
  • src/components/base-ui/Profile/BookStoryList.tsx
  • src/components/base-ui/Profile/OtherUser/ProfileUserInfo.tsx
  • src/components/base-ui/home/home_bookclub.tsx
  • src/components/layout/BottomNav.tsx
  • src/components/layout/Header.tsx
  • src/components/layout/NavItem.tsx
  • src/hooks/queries/useClubQueries.ts
  • src/hooks/queries/useSearchClubQueries.ts
  • src/hooks/queries/useStoryQueries.ts
  • src/lib/api/client.ts
  • src/services/authService.ts
🚧 Files skipped from review as they are similar to previous changes (1)
  • src/app/(main)/page.tsx

Comment on lines +23 to +25
setTimeout(() => {
openLoginModal();
}, 100);
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Remove fixed-delay modal timing in redirect flow.

Line 23-Line 25 uses a hard-coded 100ms delay, which makes modal timing race-prone. Trigger the modal deterministically, then redirect.

🛠️ Suggested change
   useEffect(() => {
     if (isInitialized && !isLoggedIn) {
       toast.error("책 이야기 글 작성하기는 로그인이 필요한 서비스입니다.", { id: "story-create-auth-error-direct" });
-      router.replace("/stories");
-      setTimeout(() => {
-        openLoginModal();
-      }, 100);
+      openLoginModal();
+      router.replace("/stories");
     }
   }, [isInitialized, isLoggedIn, router, openLoginModal]);
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
setTimeout(() => {
openLoginModal();
}, 100);
useEffect(() => {
if (isInitialized && !isLoggedIn) {
toast.error("책 이야기 글 작성하기는 로그인이 필요한 서비스입니다.", { id: "story-create-auth-error-direct" });
openLoginModal();
router.replace("/stories");
}
}, [isInitialized, isLoggedIn, router, openLoginModal]);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/app/`(main)/stories/new/page.tsx around lines 23 - 25, Replace the
race-prone setTimeout wrapper around openLoginModal with a deterministic flow:
remove setTimeout(...) and invoke openLoginModal() directly when the redirect
condition is hit, then perform the redirect only after the modal action resolves
(either by awaiting openLoginModal() if it returns a promise or by moving the
redirect into the modal's onClose/confirmation callback). Ensure you remove the
hard-coded 100ms delay and wire the redirect to the modal's resolution so the
sequence is deterministic.

Comment on lines +58 to +100
const isFollowingTab = selectedCategory === "following";

const {
storiesData,
isLoadingStories,
isErrorStories,
fetchNextPage,
hasNextPage,
isFetchingNextPage,
} = useInfiniteStoriesQuery();
} = useMemo(() => {
if (isFollowingTab) {
return {
storiesData: followingStoriesData,
isLoadingStories: isLoadingFollowingStories,
isErrorStories: isErrorFollowingStories,
fetchNextPage: fetchNextFollowingPage,
hasNextPage: hasNextFollowingPage,
isFetchingNextPage: isFetchingNextFollowingPage,
};
}
return {
storiesData: defaultStoriesData,
isLoadingStories: isLoadingDefaultStories,
isErrorStories: isErrorDefaultStories,
fetchNextPage: fetchNextDefaultPage,
hasNextPage: hasNextDefaultPage,
isFetchingNextPage: isFetchingNextDefaultPage,
};
}, [
isFollowingTab,
followingStoriesData,
isLoadingFollowingStories,
isErrorFollowingStories,
fetchNextFollowingPage,
hasNextFollowingPage,
isFetchingNextFollowingPage,
defaultStoriesData,
isLoadingDefaultStories,
isErrorDefaultStories,
fetchNextDefaultPage,
hasNextDefaultPage,
isFetchingNextDefaultPage,
]);
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Club tabs are currently non-functional because they don’t change the active story source.

selectedCategory is updated with club IDs, but the data switch only checks "following" vs default. So selecting any club tab still renders the default feed, which is a functional mismatch in the tab UI.

Suggested direction
- const isFollowingTab = selectedCategory === "following";
+ const isFollowingTab = selectedCategory === "following";
+ const isClubTab = selectedCategory !== "all" && selectedCategory !== "following";

  const {
    storiesData,
    isLoadingStories,
    isErrorStories,
    fetchNextPage,
    hasNextPage,
    isFetchingNextPage,
  } = useMemo(() => {
+   if (isClubTab) {
+     // TODO: wire club-specific query/filter using selectedCategory (clubId)
+     // return clubStories surface here
+   }
    if (isFollowingTab) {
      return {
        storiesData: followingStoriesData,
        isLoadingStories: isLoadingFollowingStories,
        isErrorStories: isErrorFollowingStories,
        fetchNextPage: fetchNextFollowingPage,
        hasNextPage: hasNextFollowingPage,
        isFetchingNextPage: isFetchingNextFollowingPage,
      };
    }

Also applies to: 163-170

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/app/`(main)/stories/page.tsx around lines 58 - 100, The current useMemo
only switches between "following" and default feeds, so club tabs (where
selectedCategory holds a club ID) still render the default feed; update the
useMemo to branch for three cases: when selectedCategory === "following" use
followingStoriesData / isLoadingFollowingStories / isErrorFollowingStories /
fetchNextFollowingPage / hasNextFollowingPage / isFetchingNextFollowingPage;
when selectedCategory represents a club (e.g., selectedCategory !== "following"
&& selectedCategory !== "default" or explicitly check a club id flag) use the
club feed variables (clubStoriesData, isLoadingClubStories, isErrorClubStories,
fetchNextClubPage, hasNextClubPage, isFetchingNextClubPage); otherwise fall back
to defaultStoriesData and its related flags (defaultStoriesData,
isLoadingDefaultStories, etc.); also add the club-related variables and
selectedCategory into the useMemo dependency array so the memo updates when the
active club tab changes.

Comment on lines +184 to +186
} catch (e: any) {
toast.error(e?.message ?? "가입 신청에 실패했습니다.");
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Replace any with a typed error.

The any type defeats TypeScript's error checking. Use unknown and narrow the type, or define a specific error type.

🛠️ Proposed fix
-    } catch (e: any) {
-      toast.error(e?.message ?? "가입 신청에 실패했습니다.");
+    } catch (e: unknown) {
+      const message = e instanceof Error ? e.message : "가입 신청에 실패했습니다.";
+      toast.error(message);
     }
🧰 Tools
🪛 ESLint

[error] 184-184: Unexpected any. Specify a different type.

(@typescript-eslint/no-explicit-any)

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/app/groups/page.tsx` around lines 184 - 186, In the catch block that
currently reads "catch (e: any)" replace the any with unknown and narrow the
type before using message: change to "catch (e: unknown)" then check e with a
type guard (e.g., "if (e instanceof Error)") to call toast.error(e.message),
otherwise call toast.error(String(e) || "가입 신청에 실패했습니다."); update the catch in
the same function / block where toast.error is invoked so the code is type-safe.

Comment on lines +189 to 204
const onSubmitSearch = () => {
const keyword = q.trim();

if (!keyword) {
setAppliedParams(null);
return;
}

setAppliedParams({
outputFilter: mapCategoryToOutputFilter(category),
inputFilter: mapInputFilter(group, region),
keyword,
});

refetchSearch();
};
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Remove redundant refetchSearch() call.

Since useInfiniteClubSearchQuery includes appliedParams in its query key, React Query will automatically fetch fresh data when setAppliedParams() updates the state. The explicit refetchSearch() call is redundant and may cause a race condition where it fires before React processes the state update, potentially triggering a fetch with stale params.

🐛 Proposed fix
 const onSubmitSearch = () => {
   const keyword = q.trim();

   if (!keyword) {
     setAppliedParams(null);
     return;
   }

   setAppliedParams({
     outputFilter: mapCategoryToOutputFilter(category),
     inputFilter: mapInputFilter(group, region),
     keyword,
   });
-
-  refetchSearch();
 };
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const onSubmitSearch = () => {
const keyword = q.trim();
if (!keyword) {
setAppliedParams(null);
return;
}
setAppliedParams({
outputFilter: mapCategoryToOutputFilter(category),
inputFilter: mapInputFilter(group, region),
keyword,
});
refetchSearch();
};
const onSubmitSearch = () => {
const keyword = q.trim();
if (!keyword) {
setAppliedParams(null);
return;
}
setAppliedParams({
outputFilter: mapCategoryToOutputFilter(category),
inputFilter: mapInputFilter(group, region),
keyword,
});
};
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/app/groups/page.tsx` around lines 189 - 204, The onSubmitSearch handler
is calling refetchSearch() after setAppliedParams(), which is redundant because
useInfiniteClubSearchQuery uses appliedParams in its query key and will refetch
automatically; remove the explicit refetchSearch() invocation from
onSubmitSearch (leave the setAppliedParams(...) and the early
setAppliedParams(null) path intact) so updates go through React state and React
Query without risking a race with stale params.

Comment on lines +16 to +36
const { isLoggedIn, openLoginModal } = useAuthStore();
const count = groups.length;
const isMany = count >= 5;

const [open, setOpen] = useState(false);

const handleCreateGroup = () => {
if (!isLoggedIn) {
openLoginModal();
return;
}
router.push('/groups/create');
};

const handleSearchGroup = () => {
if (!isLoggedIn) {
openLoginModal();
return;
}
router.push('/groups');
};
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Guard against pre-initialization false negatives in auth checks.

At Line 23 and Line 31, !isLoggedIn can fire before auth hydration/initialization completes, which may incorrectly open the login modal for already-authenticated users. Since useAuthStore exposes isInitialized, gate on that first and centralize the auth check to remove duplication.

Proposed fix
-  const { isLoggedIn, openLoginModal } = useAuthStore();
+  const { isLoggedIn, isInitialized, openLoginModal } = useAuthStore();

+  const requireAuth = (onAuthenticated: () => void) => {
+    if (!isInitialized) return;
+    if (!isLoggedIn) {
+      openLoginModal();
+      return;
+    }
+    onAuthenticated();
+  };
+
   const handleCreateGroup = () => {
-    if (!isLoggedIn) {
-      openLoginModal();
-      return;
-    }
-    router.push('/groups/create');
+    requireAuth(() => router.push('/groups/create'));
   };

   const handleSearchGroup = () => {
-    if (!isLoggedIn) {
-      openLoginModal();
-      return;
-    }
-    router.push('/groups');
+    requireAuth(() => router.push('/groups'));
   };
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const { isLoggedIn, openLoginModal } = useAuthStore();
const count = groups.length;
const isMany = count >= 5;
const [open, setOpen] = useState(false);
const handleCreateGroup = () => {
if (!isLoggedIn) {
openLoginModal();
return;
}
router.push('/groups/create');
};
const handleSearchGroup = () => {
if (!isLoggedIn) {
openLoginModal();
return;
}
router.push('/groups');
};
const { isLoggedIn, isInitialized, openLoginModal } = useAuthStore();
const count = groups.length;
const isMany = count >= 5;
const [open, setOpen] = useState(false);
const requireAuth = (onAuthenticated: () => void) => {
if (!isInitialized) return;
if (!isLoggedIn) {
openLoginModal();
return;
}
onAuthenticated();
};
const handleCreateGroup = () => {
requireAuth(() => router.push('/groups/create'));
};
const handleSearchGroup = () => {
requireAuth(() => router.push('/groups'));
};
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/components/base-ui/home/home_bookclub.tsx` around lines 16 - 36, The auth
checks in handleCreateGroup and handleSearchGroup use !isLoggedIn too early and
can misfire before auth is hydrated; modify usage of useAuthStore to also read
isInitialized and centralize the logic by adding a helper (e.g.,
ensureAuthenticatedOrOpenLogin) that first checks isInitialized and then
isLoggedIn, calling openLoginModal only when initialized && !isLoggedIn, and use
that helper inside handleCreateGroup and handleSearchGroup to remove duplication
and prevent pre-initialization false negatives.

Comment on lines +58 to +59
const decodedNickname = decodeURIComponent(nickname);
const { data: profile, isLoading } = useOtherProfileQuery(decodedNickname);
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🌐 Web query:

Next.js App Router dynamic route parameters URL decoded before component

💡 Result:

In the App Router, dynamic route params are URL-decoded before your page/layout/route code runs (and before useParams() returns them). Next.js’ route matcher calls decodeURIComponent() on every captured param when it builds the params object. [1]

So:

  • /items/hello%20worldparams.slug === "hello world" (already decoded). [1]
  • There isn’t a supported way to get the “raw” percent-encoded value from params; if you must preserve reserved characters (notably %2F), you typically need to encode differently (e.g., base64url) or double-encode and decode yourself at the edge/origin. [1]

useParams() just exposes these already-decoded dynamic params to Client Components. [2]

Sources: [1] [2]


🌐 Web query:

decodeURIComponent behavior already decoded strings percent encoding

💡 Result:

decodeURIComponent(str) only acts on percent-escapes. Everything else is left as-is.

If the string is “already decoded”

  • If it contains no %XX sequences, decodeURIComponent just returns the same string (it doesn’t “re-encode” or change normal characters). It even tolerates unescaped Unicode characters (e.g., emoji) as plain input. [4]

When it throws (URIError)

decodeURIComponent throws URIError: malformed URI sequence when it sees percent-encoding it can’t decode, for example:

  • a % not followed by exactly two hex digits (e.g. "100% free", "%2", "%AZ"), or
  • bytes that don’t form valid UTF‑8 for a character. [1], [4], [5]
    In general: malformed escape sequences ⇒ URIError. [2]

“Double decoding” and %25

If input was encoded twice, you may see patterns like %2520 (that’s %25 = literal %, then 20). Decoding once turns %2520 into the literal text %20; decoding again would turn that into a space. This is why decoding twice can silently change data when literal % is involved. [3], [6]

Practical guidance

  • Decode exactly once at a well-defined boundary (e.g., when reading a query param).
  • If input may be messy/untrusted, wrap in try/catch (or pre-validate) because a single bad % makes it throw. [1]
  • Prefer higher-level parsers like URLSearchParams for query strings; it’s generally more tolerant than decodeURIComponent of non-encoded characters while still handling normal decoding. [4]

Sources:
[1] MDN: “URIError: malformed URI sequence” (decodeURIComponent failure cases)
[2] O’Reilly: decodeURIComponent throws URIError on malformed escapes
[3] Unspecified Behaviour blog: double-decoding pitfalls
[4] TEKNKL blog: decodeURIComponent tolerates unencoded input but is strict about bad % escapes
[5] Stack Overflow: decodeURIComponent expects UTF‑8 percent-encoding
[6] UTMGuard: %25 patterns and double-encoding examples


Remove redundant decodeURIComponent — Next.js App Router pre-decodes route params.

In Next.js App Router, dynamic route params are already URL-decoded before reaching client components via useParams(). Calling decodeURIComponent() again causes:

  • Throws URIError if the decoded nickname contains % not followed by two hex digits (e.g., "100% discount")
  • Silent data corruption if the original was double-encoded (e.g., %2520 becomes %20, then becomes a space)

Remove the call entirely:

🔧 Proposed fix
-  const decodedNickname = decodeURIComponent(nickname);
+  const decodedNickname = nickname;

If you must keep defensive decoding for edge cases, wrap in try-catch, but the param is already safe to use as-is.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/components/base-ui/Profile/OtherUser/ProfileUserInfo.tsx` around lines 58
- 59, Remove the redundant decodeURIComponent call around the route param: use
the raw nickname param directly when calling useOtherProfileQuery (replace
decodedNickname with nickname) because Next.js App Router already decodes
params; locate the decodedNickname const and the
useOtherProfileQuery(decodedNickname) usage in ProfileUserInfo and remove the
decodeURIComponent invocation (if you want extra safety, optionally wrap a
defensive decode in a try/catch around a separate variable, but prefer using
nickname as-is).

Comment on lines 140 to 143
<div className="flex items-center gap-[12px]">
<StatItem label="구독 중" count={2} />
<StatItem label="구독자" count={2} />
<StatItem label="구독 중" count={0} />
<StatItem label="구독자" count={0} />
</div>
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Verify actual count field names on OtherProfileResponse and where they are populated.
rg -n --type=ts -C3 '\b(interface|type)\s+OtherProfileResponse\b|follower|following|subscriber|subscription|count' src
rg -n --type=ts -C3 'getOtherProfile\(|otherProfile' src

Repository: checkmo2025/FE

Length of output: 50370


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Find ProfileUserInfo usage and component definition
rg -n "ProfileUserInfo" src --type=ts -B2 -A5

# Check how this component is used and what props it receives
fd "ProfileUserInfo.tsx" src --exec head -100 {}

Repository: checkmo2025/FE

Length of output: 5561


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Find useOtherProfileQuery implementation
rg -n "useOtherProfileQuery" src --type=ts -A10

# Check memberService.getOtherProfile
rg -n "getOtherProfile" src --type=ts -B2 -A8

Repository: checkmo2025/FE

Length of output: 5036


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Search for places displaying "follower" or "subscriber" counts for other users
rg -n "followerCount|subscriberCount|followers|followings" src/components/base-ui/Profile --type=ts -B2 -A2

# Check if there are other type definitions related to profiles that might have counts
rg -n "interface.*Profile|type.*Profile" src/types --type=ts -A8

# Look for any comments or TODOs about missing counts
rg -n "TODO|FIXME|구독|follower|count" src/components/base-ui/Profile/OtherUser/ProfileUserInfo.tsx -B1 -A1

Repository: checkmo2025/FE

Length of output: 3539


Add follower and subscriber count fields to OtherProfileResponse type and update the component to display them.

The hardcoded 0 values in lines 141-142 cannot reflect actual stats. While UserProfileData (for the current user) includes following and subscribers counts, OtherProfileResponse is missing these fields. Add followersCount: number and followingCount: number to the OtherProfileResponse interface in src/types/member.ts, then update the StatItem calls to use profile.followersCount and profile.followingCount respectively.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/components/base-ui/Profile/OtherUser/ProfileUserInfo.tsx` around lines
140 - 143, OtherProfileResponse is missing follower/following counts so
ProfileUserInfo shows hardcoded 0s; add followersCount: number and
followingCount: number to the OtherProfileResponse interface in
src/types/member.ts, then update the StatItem calls in ProfileUserInfo (use the
existing profile variable) to pass profile.followingCount for the "구독 중" item
and profile.followersCount for the "구독자" item so the component renders real
stats.

Comment on lines +27 to +33
if (onClick) {
return (
<div onClick={onClick} className={commonClassName}>
{content}
</div>
);
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Accessibility: clickable <div> lacks keyboard support and focus.

When onClick is provided, the component renders a <div> instead of a <Link>. This <div> is not keyboard-accessible—users cannot focus it via Tab or trigger it with Enter/Space.

Consider using a <button> (styled appropriately) or adding tabIndex={0}, role="button", and onKeyDown handling.

♿ Proposed fix for accessibility
   if (onClick) {
     return (
-      <div onClick={onClick} className={commonClassName}>
+      <button
+        type="button"
+        onClick={onClick}
+        className={commonClassName}
+      >
         {content}
-      </div>
+      </button>
     );
   }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
if (onClick) {
return (
<div onClick={onClick} className={commonClassName}>
{content}
</div>
);
}
if (onClick) {
return (
<button
type="button"
onClick={onClick}
className={commonClassName}
>
{content}
</button>
);
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/components/layout/NavItem.tsx` around lines 27 - 33, The clickable div in
NavItem.tsx (when onClick is provided) is not keyboard-accessible; update the
NavItem component to render either a semantic <button> styled with
commonClassName or, if you must keep a div, add tabIndex={0}, role="button", and
an onKeyDown handler that calls the same onClick for Enter/Space keys so
keyboard users can focus and activate the item; ensure the handler lives
alongside the existing onClick and preserves any a11y attributes (e.g.,
aria-pressed if relevant) and that content is still rendered as before.

@shinwokkang shinwokkang merged commit d53b8a9 into main Mar 1, 2026
4 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

✨ feat 새로운 기능 추가

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants