Conversation
Feat 108 group api
[fix/style] build 오류 해결 , 내모임이 많을 떄 스크롤 안생기게끔
|
제꺼 먼저점여 |
feat/150 공지 (홈,생성 관성 / 상세보기(댓글포함) 미완)
[feat] 관리자용 모임 관리, 소식 관리 상세 페이지 UI 구현
There was a problem hiding this comment.
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 | 🟡 MinorRemove 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 | 🟡 MinorSame placeholder issue as categories.
The
Targetcomponent 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 | 🟡 MinorPlaceholder 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 | 🟡 MinorRender 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
iconAlttext 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 | 🟡 MinorInconsistent groupId validation between render guard and query enablement.
The guard at line 69 uses
Number.isNaN(groupId), but the query enablement at line 29 usesNumber.isFinite(groupId) && groupId > 0. These checks have different coverage:
Number.isNaNonly catchesNaNNumber.isFinitealso rejectsInfinity/-Infinity- Neither explicitly rejects
0or negative IDs hereAlign 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 | 🟡 MinorReplace
anycast with proper generic typing foruseParams.ESLint flags the
anycast. Next.js App Router'suseParamssupports 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 | 🟡 MinorRemove 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 | 🟡 MinorPrevent hover animation on disabled chips.
Line 26 applies
hover:*styles unconditionally, so disabled chips can still show motion feedback. Useenabled: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 | 🟡 MinorHarden
enabledguards 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 | 🟡 MinorDon’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 | 🟡 MinorTighten
clubIdvalidation 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 twoBookshelfAdminMenuinstances 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
BookshelfAdminMenuinstance 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
placeholderfor context. Screen readers need proper<label>elements oraria-labelattributes.♿ 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: Scopeimages.remotePatternspathnames 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"andaria-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-backgroundAnd add an
idto 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 toBookcaseCard.Empty arrow functions are provided for
onTopicClick,onReviewClick, andonMeetingClick. If these actions aren't relevant in the book selection context, consider making these props optional inBookcaseCard(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 thereturnstatement, 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
useEffecttriggersfetchNextPage()wheneverhasNextPageorisFetchingNextPagechanges. After each fetch completes, these values update, causing the effect to re-run. TheAUTO_FETCH_LIMITguards 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:initialPageParamisnullbutgetNextPageParamcan returnundefined.
initialPageParam: null(line 93) andgetNextPageParamreturningundefined(line 101) create a type mismatch. TheTPageParamgeneric isnumber | null, butundefinedis 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
inputis 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: HardcodedisAdmin = 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
isAdminfrom 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. Adddisabledto 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 witharia-pressed.
selectedcurrently changes only visuals. Addaria-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
noticeDetailavoids 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; usingupdateNotice/deleteNoticekeeps 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
⛔ Files ignored due to path filters (4)
public/AdminEdit Square.svgis excluded by!**/*.svgpublic/AdminEdit.svgis excluded by!**/*.svgpublic/AdminUser.svgis excluded by!**/*.svgpublic/icon_minus_2.svgis excluded by!**/*.svg
📒 Files selected for processing (28)
next.config.tssrc/app/(admin)/admin/(app)/groups/[id]/members/page.tsxsrc/app/(admin)/admin/(app)/groups/[id]/page.tsxsrc/app/(admin)/admin/(app)/groups/page.tsxsrc/app/(admin)/admin/(app)/news/[id]/page.tsxsrc/app/(admin)/admin/(app)/news/new/page.tsxsrc/app/(admin)/admin/(app)/news/page.tsxsrc/app/groups/[id]/admin/notice/new/page.tsxsrc/app/groups/[id]/bookcase/page.tsxsrc/app/groups/[id]/layout.tsxsrc/app/groups/[id]/notice/[noticeId]/page.tsxsrc/app/groups/[id]/notice/page.tsxsrc/components/base-ui/Admin/groups/Category.tsxsrc/components/base-ui/Admin/groups/Chips.tsxsrc/components/base-ui/Admin/groups/Target.tsxsrc/components/base-ui/Admin/groups/TopItem.tsxsrc/components/base-ui/Bookcase/BookDetailCard.tsxsrc/components/base-ui/Group/BookshelfModal.tsxsrc/components/base-ui/Group/notice_item.tsxsrc/hooks/mutations/useClubNoticeCommentMutations.tssrc/hooks/mutations/useClubNotificationMutations.tssrc/hooks/queries/useClubNoticeCommentQueries.tssrc/hooks/queries/useClubNotificationQueries.tssrc/hooks/queries/useClubsBookshelfQueries.tssrc/lib/api/endpoints/Clubnotification.tssrc/services/clubNoticeCommentService.tssrc/services/clubNotificationService.tssrc/types/clubnotification.ts
| export default function MembersListPage() { | ||
| const [page, setPage] = useState(1); | ||
|
|
||
| // 모임 이름 (나중에 API로 교체 가능) | ||
| const groupName = "북적북적"; | ||
|
|
||
| // 더미 데이터 | ||
| const users: UserRow[] = useMemo(() => { | ||
| const base = [ |
There was a problem hiding this comment.
🧩 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.
| { | ||
| 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: "회원", | ||
| }, |
There was a problem hiding this comment.
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.
| { | |
| 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.
| {/* 링크 */} | ||
| <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> |
There was a problem hiding this comment.
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:
- Convert to a Client Component with state management, or
- 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.
| 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)); | ||
|
|
There was a problem hiding this comment.
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.
| const url = URL.createObjectURL(file); | ||
| setter(url); |
There was a problem hiding this comment.
🧩 Analysis chain
🏁 Script executed:
cat -n src/app/\(admin\)/admin/\(app\)/news/new/page.tsx | head -50Repository: checkmo2025/FE
Length of output: 2027
🏁 Script executed:
# Search for all createObjectURL and revokeObjectURL usage in the codebase
rg -n 'createObjectURL|revokeObjectURL' --type tsxRepository: checkmo2025/FE
Length of output: 85
🏁 Script executed:
# Search for revokeObjectURL usage in all files (broader search)
rg -n 'revokeObjectURL' --type-list | head -20Repository: 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 -100Repository: 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.
| 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]); |
There was a problem hiding this comment.
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).
| 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" | ||
| /> |
There was a problem hiding this comment.
🧩 Analysis chain
🏁 Script executed:
cat -n src/components/base-ui/Admin/groups/TopItem.tsx | head -40Repository: checkmo2025/FE
Length of output: 1455
🏁 Script executed:
rg -n --type=ts --type=tsx '<GroupTopSection\b|coverImgSrc\s*=' -C2Repository: checkmo2025/FE
Length of output: 85
🏁 Script executed:
rg -n 'GroupTopSection' --type=ts --type=js --type=tsx --type=jsx -B2 -A2Repository: checkmo2025/FE
Length of output: 85
🏁 Script executed:
rg -n 'coverImgSrc' --type=ts --type=js --type=tsx --type=jsx -B2 -A2Repository: checkmo2025/FE
Length of output: 85
🏁 Script executed:
rg -n 'GroupTopSection' -B2 -A2Repository: checkmo2025/FE
Length of output: 375
🏁 Script executed:
rg -n 'coverImgSrc' -B2 -A2Repository: 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.
| <BookcaseCard | ||
| title={book.title} | ||
| author={book.author} | ||
| category={book.category} | ||
| rating={book.rating} | ||
| onTopicClick={() => {}} | ||
| onReviewClick={() => {}} | ||
| onMeetingClick={() => {}} | ||
| imageUrl={book.imageUrl ?? ""} | ||
| /> |
There was a problem hiding this comment.
🧩 Analysis chain
🏁 Script executed:
# Find BookcaseCard component location
fd -t f 'BookcaseCard' --extension ts --extension tsxRepository: 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 -50Repository: 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 1Repository: 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.
| <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"} |
There was a problem hiding this comment.
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.
| <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.
| 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), | ||
| }); | ||
| } |
There was a problem hiding this comment.
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.
| 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.
…tom nav, and direct URL
Fix/155 home fix
There was a problem hiding this comment.
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 | 🟠 MajorSame
menuRefassigned to two different elements.The
menuRefis 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.jsImageandLinkwork 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
setTimeoutwith 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: AvoidsetStatedirectly inuseEffectto prevent cascading renders.The ESLint rule
react-hooks/set-state-in-effectflags this pattern. Sincecommentsis derived frominitialComments, consider usinguseMemoor 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
mapApiToUiCommentsis 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
ifstatements. 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
onClickis passed toNavItem, it renders a<div>and callsrouter.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
onClickfor gated items, or modifyingNavItemto use<Link>with anonClickthat canpreventDefault()when needed (similar toBottomNav.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
handleReportSubmitfunction duplicates the same string-to-ReportTypemapping found incomment_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
BookStoryCardLargeprops. 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
ifmapping 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
📒 Files selected for processing (19)
src/app/(main)/notification/page.tsxsrc/app/(main)/page.tsxsrc/app/(main)/stories/[id]/page.tsxsrc/app/(main)/stories/new/page.tsxsrc/app/(main)/stories/page.tsxsrc/app/groups/page.tsxsrc/components/base-ui/BookStory/bookstory_detail.tsxsrc/components/base-ui/Comment/comment_section.tsxsrc/components/base-ui/Profile/BookStoryList.tsxsrc/components/base-ui/Profile/OtherUser/ProfileUserInfo.tsxsrc/components/base-ui/home/home_bookclub.tsxsrc/components/layout/BottomNav.tsxsrc/components/layout/Header.tsxsrc/components/layout/NavItem.tsxsrc/hooks/queries/useClubQueries.tssrc/hooks/queries/useSearchClubQueries.tssrc/hooks/queries/useStoryQueries.tssrc/lib/api/client.tssrc/services/authService.ts
🚧 Files skipped from review as they are similar to previous changes (1)
- src/app/(main)/page.tsx
| setTimeout(() => { | ||
| openLoginModal(); | ||
| }, 100); |
There was a problem hiding this comment.
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.
| 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.
| 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, | ||
| ]); |
There was a problem hiding this comment.
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.
| } catch (e: any) { | ||
| toast.error(e?.message ?? "가입 신청에 실패했습니다."); | ||
| } |
There was a problem hiding this comment.
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.
| const onSubmitSearch = () => { | ||
| const keyword = q.trim(); | ||
|
|
||
| if (!keyword) { | ||
| setAppliedParams(null); | ||
| return; | ||
| } | ||
|
|
||
| setAppliedParams({ | ||
| outputFilter: mapCategoryToOutputFilter(category), | ||
| inputFilter: mapInputFilter(group, region), | ||
| keyword, | ||
| }); | ||
|
|
||
| refetchSearch(); | ||
| }; |
There was a problem hiding this comment.
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.
| 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.
| 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'); | ||
| }; |
There was a problem hiding this comment.
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.
| 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.
| const decodedNickname = decodeURIComponent(nickname); | ||
| const { data: profile, isLoading } = useOtherProfileQuery(decodedNickname); |
There was a problem hiding this comment.
🧩 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%20world⇒params.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
%XXsequences,decodeURIComponentjust 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
URLSearchParamsfor query strings; it’s generally more tolerant thandecodeURIComponentof 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
URIErrorif 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.,
%2520becomes%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).
| <div className="flex items-center gap-[12px]"> | ||
| <StatItem label="구독 중" count={2} /> | ||
| <StatItem label="구독자" count={2} /> | ||
| <StatItem label="구독 중" count={0} /> | ||
| <StatItem label="구독자" count={0} /> | ||
| </div> |
There was a problem hiding this comment.
🧩 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' srcRepository: 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 -A8Repository: 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 -A1Repository: 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.
| if (onClick) { | ||
| return ( | ||
| <div onClick={onClick} className={commonClassName}> | ||
| {content} | ||
| </div> | ||
| ); | ||
| } |
There was a problem hiding this comment.
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.
| 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.
📌 개요 (Summary)
🛠️ 변경 사항 (Changes)
📸 스크린샷 (Screenshots)
(UI 변경 사항이 있다면 첨부해주세요)
✅ 체크리스트 (Checklist)
pnpm build)pnpm lint)Summary by CodeRabbit