Feat/139 모임홈, 모임 관리, 책장 ,책장 상세 ,정기모임(+운영진페이지들)#147
Conversation
|
Important Review skippedAuto reviews are disabled on base/target branches other than the default branch. Please check the settings in the CodeRabbit UI or the You can disable this status message by setting the Use the checkbox below for a quick retry:
✨ Finishing Touches🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
Summary of ChangesHello, I'm Gemini Code Assist1! I'm currently reviewing this pull request and will post my feedback shortly. In the meantime, here's a summary to help you and other reviewers quickly get up to speed! 이 Pull Request는 모임 관련 기능의 백엔드 API 통합을 통해 전반적인 사용자 경험과 운영 효율성을 크게 향상시킵니다. 특히 모임 홈, 회원 관리, 책장 관리 기능이 실제 데이터 기반으로 전환되었으며, 모임 및 책장 정보 수정 페이지가 새로 도입되어 운영진의 관리 편의성이 증대되었습니다. 이를 통해 더 안정적이고 기능적인 모임 플랫폼을 제공합니다. Highlights
🧠 New Feature in Public Preview: You can now enable Memory to help Gemini Code Assist learn from your team's feedback. This makes future code reviews more consistent and personalized to your project's style. Click here to enable Memory in your admin console. Changelog
Using Gemini Code AssistThe full guide for Gemini Code Assist can be found on our documentation page, here are some quick tips. Invoking Gemini You can request assistance from Gemini at any point by creating a comment using either
Customization To customize Gemini Code Assist for GitHub experience, repository maintainers can create a configuration file and/or provide a custom code review style guide (such as PEP-8 for Python) by creating and adding files to a Limitations & Feedback Gemini Code Assist may make mistakes. Please leave feedback on any instances where its feedback is incorrect or counter productive. You can react with 👍 and 👎 on @gemini-code-assist comments. If you're interested in giving your feedback about your experience with Gemini Code Assist for Github and other Google products, sign up here. You can also get AI-powered code generation, chat, as well as code reviews directly in the IDE at no cost with the Gemini Code Assist IDE Extension. Footnotes
|
| open: open === true, | ||
| }; | ||
|
|
||
| await updateClub.mutateAsync(payload as any); |
src/types/groups/clubAdminEdit.ts
Outdated
| export type UpdateClubAdminRequest = { | ||
| name: string; | ||
| description: string; | ||
| profileImageUrl: string; |
There was a problem hiding this comment.
| : 'bg-primary-2 text-White hover:opacity-90' | ||
| }`} | ||
| > | ||
| 등록 |
| </div> | ||
|
|
||
| <div className="mx-auto w-full max-w-[1440px] px-4 t:px-6 py-3 t:pt-3 t:pb-8"> | ||
| <div className="mx-auto w-full max-w-[1440px] px-auto t:px-6 py-3 t:pt-3 t:pb-8"> |
There was a problem hiding this comment.
px-auto는 유효하지 않은 Tailwind CSS 클래스입니다. 의도하신 스타일이 padding-left: auto; padding-right: auto;가 아니라면, px-0 등으로 수정하거나 제거하는 것을 고려해 보세요. mx-auto가 이미 적용되어 있어 수평 가운데 정렬은 되고 있습니다.
| <div className="mx-auto w-full max-w-[1440px] px-auto t:px-6 py-3 t:pt-3 t:pb-8"> | |
| <div className="mx-auto w-full max-w-[1440px] t:px-6 py-3 t:pt-3 t:pb-8"> |
Invalidate queries for both PENDING and ALL club members after updating status.
There was a problem hiding this comment.
Actionable comments posted: 13
Note
Due to the large number of review comments, Critical, Major severity comments were prioritized as inline comments.
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (1)
src/app/groups/[id]/admin/applicant/page.tsx (1)
71-87:⚠️ Potential issue | 🟡 MinorImprove accessibility for
JoinMessageModalbackdrop.The backdrop
<div>withonClickis not keyboard accessible. Users navigating with keyboards cannot close the modal by clicking the backdrop.🔧 Proposed fix using button element
function JoinMessageModal({ isOpen, onClose, message }: JoinMessageModalProps) { if (!isOpen) return null; return ( - <div - className="fixed inset-0 z-50 flex items-center justify-center bg-black/30 px-4" - onClick={onClose} - > + <div className="fixed inset-0 z-50 flex items-center justify-center px-4"> + <button + type="button" + onClick={onClose} + className="absolute inset-0 bg-black/30" + aria-label="닫기" + /> <div className="w-100 h-45 rounded-lg bg-White p-6 overflow-y-auto" - onClick={(e) => e.stopPropagation()} > <p className="body_1_2 text-Gray-4 whitespace-pre-wrap">{message}</p> </div> </div> ); }🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@src/app/groups/`[id]/admin/applicant/page.tsx around lines 71 - 87, The backdrop div in JoinMessageModal is not keyboard accessible; update the component so the backdrop element can be activated via keyboard — either replace the backdrop div with a semantic interactive element (e.g., a button) or add role="button", tabIndex={0} and an onKeyDown handler that calls onClose for Enter/Space and also handle Escape to close, while preserving the existing onClick and stopPropagation behavior on the inner content; ensure these changes are made inside the JoinMessageModal function and keep props isOpen, onClose, and message unchanged.
🟡 Minor comments (12)
src/app/groups/[id]/layout.tsx-92-92 (1)
92-92:⚠️ Potential issue | 🟡 Minor
px-autois not a valid Tailwind CSS class.CSS
paddingproperties do not acceptautoas a value. This class will have no effect and may cause confusion. If horizontal centering is intended,mx-autois already applied on parent elements. If padding is needed, use a specific value likepx-4orpx-6.🔧 Proposed fix
- <div className="mx-auto w-full max-w-[1440px] px-auto t:px-6 py-3 t:pt-3 t:pb-8"> + <div className="mx-auto w-full max-w-[1440px] t:px-6 py-3 t:pt-3 t:pb-8">🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@src/app/groups/`[id]/layout.tsx at line 92, The div rendering the page container uses an invalid Tailwind class "px-auto" (see the className string "mx-auto w-full max-w-[1440px] px-auto t:px-6 py-3 t:pt-3 t:pb-8"); replace "px-auto" with a valid padding utility (e.g., "px-6" or "px-4") or remove it if horizontal centering is already handled by "mx-auto" — update the className on that container element in layout.tsx accordingly.src/components/base-ui/Group-Search/search_clublist/search_club_category_tags.tsx-50-84 (1)
50-84:⚠️ Potential issue | 🟡 MinorMixed array type handling could be fragile.
The type discrimination using
typeof list[0] === "number"assumes homogeneous arrays. If an array contains both numbers and DTOs, the behavior would be incorrect—numbers would be treated as DTOs.🛡️ Suggested defensive check
const list = Array.isArray(category) ? category : []; - // ✅ number[]로 들어온 경우 (기존) - if (list.length > 0 && typeof list[0] === "number") { + // ✅ number[]로 들어온 경우 (기존) - all items must be numbers + const allNumbers = list.length > 0 && list.every((item) => typeof item === "number"); + if (allNumbers) { const nums = Array.from(new Set(list as number[]))🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@src/components/base-ui/Group-Search/search_clublist/search_club_category_tags.tsx` around lines 50 - 84, The component ClubCategoryTags currently decides the numeric-ID path by checking typeof list[0] === "number", which fails for mixed arrays; change that check to verify all elements are numbers (e.g., Array.isArray(category) && list.every((x) => typeof x === "number")) or explicitly extract numeric IDs via list.filter((x): x is number => typeof x === "number") and use that nums array for LABEL/getBgByNumberCategory rendering; ensure you reference Props/category, LABEL, getBgByNumberCategory and return null if no valid nums after filtering.src/types/groups/bookcasedetail.ts-59-62 (1)
59-62:⚠️ Potential issue | 🟡 MinorInconsistent nullability for
profileImageUrlbetween similar types.In
src/types/groups/bookcasedetail.ts, theMemberInfotype (line 3) hasprofileImageUrl: string | null, but theMeetingMemberInfotype (line 61) defines it as non-nullablestring. This inconsistency is problematic since both represent member information and the codebase already includes defensive null-handling patterns (e.g.,?? DEFAULT_PROFILE,?? nullconversions) suggesting the API may return null values.Update
MeetingMemberInfoto match the nullable definition:Suggested fix
export type MeetingMemberInfo = { nickname: string; - profileImageUrl: string; + profileImageUrl: string | null; };🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@src/types/groups/bookcasedetail.ts` around lines 59 - 62, The MeetingMemberInfo type's profileImageUrl is currently non-nullable while MemberInfo uses string | null; change MeetingMemberInfo.profileImageUrl to be nullable (string | null) to match MemberInfo and existing null-handling patterns (update the MeetingMemberInfo type definition where declared and any related type aliases or imports if present).src/app/groups/page.tsx-73-73 (1)
73-73:⚠️ Potential issue | 🟡 MinorAvoid blind casting for
participantTypes.
p.code as ParticipantTypebypasses validation; unknown codes can leak into UI state. Prefer a mapper/type guard before assignment.💡 Suggested fix
+const PARTICIPANT_TYPES: ParticipantType[] = ["대학생", "직장인", "기타"]; // 실제 유니온 값으로 교체 +const isParticipantType = (v: string): v is ParticipantType => + PARTICIPANT_TYPES.includes(v as ParticipantType); function mapClubDTOToSummary(club: ClubDTO, myStatus: string, reason = ""): ClubSummary { return { @@ - participantTypes: club.participantTypes.map((p) => p.code as ParticipantType).filter(Boolean), + participantTypes: club.participantTypes.map((p) => p.code).filter(isParticipantType), }; }🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@src/app/groups/page.tsx` at line 73, The current blind cast in participantTypes (club.participantTypes.map((p) => p.code as ParticipantType).filter(Boolean)) can introduce invalid values; replace the cast with a safe mapper/type-guard: implement an isParticipantType(code: string): code is ParticipantType (or a mapParticipantCode that returns ParticipantType|undefined) and use club.participantTypes.map(p => isParticipantType(p.code) ? p.code : undefined).filter(Boolean) so only validated ParticipantType values end up in participantTypes.src/app/groups/[id]/bookcase/page.tsx-99-104 (1)
99-104:⚠️ Potential issue | 🟡 MinorFix
iconAltto match the button's purpose.The
iconAltis "문의하기" (Contact/Inquiry) but the button navigates to bookcase creation. This is misleading for screen reader users.🔧 Proposed fix
<FloatingFab iconSrc="/icons_pencil.svg" - iconAlt="문의하기" + iconAlt="새 책장 추가" onClick={() => router.push(`/groups/${groupId}/admin/bookcase/new`)} />Apply to both occurrences (lines 100-104 and 148-152).
Also applies to: 147-153
🤖 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 - 104, The FloatingFab's iconAlt is incorrect for the action; update the iconAlt prop on both FloatingFab instances (the ones rendering when isStaff with onClick={() => router.push(`/groups/${groupId}/admin/bookcase/new`)}) to describe "Create bookcase" (or the appropriate localized label for bookcase creation) so screen readers reflect the actual button purpose; change the iconAlt value wherever FloatingFab is used for bookcase creation (both occurrences).src/app/groups/[id]/bookcase/page.tsx-53-68 (1)
53-68:⚠️ Potential issue | 🟡 Minor
adaptedResponsehas inconsistent pagination metadata.The
hasNextandnextCursorvalues are taken from the first page, butmergedBookShelfInfoListcontains items from all fetched pages. After fetching multiple pages,hasNextwill be stale (reflecting the first page's state, not the last).If
adaptedResponseis required for type compatibility, consider using the last page's metadata:🔧 Proposed fix
const adaptedResponse: BookcaseApiResponse | null = useMemo(() => { if (!data?.pages?.length) return null; - const first = data.pages[0]; + const lastPage = data.pages[data.pages.length - 1]; return { isSuccess: true, code: "COMMON200", message: "성공입니다.", result: { bookShelfInfoList: mergedBookShelfInfoList, - hasNext: Boolean(first.hasNext), - nextCursor: first.nextCursor == null ? null : String(first.nextCursor), + hasNext: Boolean(lastPage.hasNext), + nextCursor: lastPage.nextCursor == null ? null : String(lastPage.nextCursor), }, }; }, [data, mergedBookShelfInfoList]);🤖 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 53 - 68, adaptedResponse is using the first page's pagination metadata while mergedBookShelfInfoList contains all pages, causing stale hasNext/nextCursor; update the logic in the useMemo that builds adaptedResponse to derive hasNext and nextCursor from the last page (e.g., const last = data.pages[data.pages.length - 1]) instead of first, and keep the existing null-safe handling for nextCursor (last.nextCursor == null ? null : String(last.nextCursor)) so pagination reflects the full merged result.src/app/groups/[id]/admin/bookcase/new/page.tsx-169-175 (1)
169-175:⚠️ Potential issue | 🟡 MinorAvoid
catch (e: any)in mutation error handling.Use
unknownand narrow the type before accessing nested properties.💡 Suggested fix
- } catch (e: any) { + } catch (e: unknown) { console.error(e); - const msg = - e?.response?.data?.message || - e?.message || - '책장 생성에 실패했습니다.'; + const err = e as { response?: { data?: { message?: string } }; message?: string }; + const msg = err.response?.data?.message || err.message || '책장 생성에 실패했습니다.'; toast.error(msg); }🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@src/app/groups/`[id]/admin/bookcase/new/page.tsx around lines 169 - 175, Replace the catch (e: any) with catch (e: unknown) and narrow the error before reading nested properties: create a small type-guard or helper (e.g., getErrorMessage(error: unknown): string) that checks for Axios-like shape (error is object && 'response' in error && typeof (error as any).response === 'object') and for message fields, fallback to typeof error === 'string' or error instanceof Error, then return a safe string; use that helper in the catch block (the throw/catch around the bookcase creation logic in page.tsx) and pass the returned string into toast.error instead of directly accessing e?.response?.data?.message.src/app/groups/[id]/bookcase/[bookId]/page.tsx-239-239 (1)
239-239:⚠️ Potential issue | 🟡 MinorRemove
as anycasts and type the useMemo hooks directly against component props.Both
topicItemsandreviewItemsare correctly shaped for their respective components but useas anyto bypass type checking. Apply proper typing to restore compile-time safety:Suggested typing pattern
+import type { DebateItem } from "./DebateSection"; +import type { ReviewItem } from "./ReviewSection"; - const topicItems = useMemo(() => { + const topicItems = useMemo<DebateItem[]>(() => { const list = topicsQuery.data?.pages.flatMap((p) => p.topicDetailList) ?? []; return list.map((t) => ({ ... })); }, [topicsQuery.data]); - const reviewItems = useMemo(() => { + const reviewItems = useMemo<ReviewItem[]>(() => { const list = reviewsQuery.data?.pages.flatMap((p) => p.bookReviewDetailList) ?? []; return list.map((r) => ({ ... })); }, [reviewsQuery.data]); - items={topicItems as any} + items={topicItems} - items={reviewItems as any} + items={reviewItems}🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@src/app/groups/`[id]/bookcase/[bookId]/page.tsx at line 239, Remove the unsafe "as any" casts on topicItems and reviewItems and instead type the useMemo hooks to return the exact prop types expected by the target components: annotate the useMemo that produces topicItems with the TopicList/TopicItem prop type (e.g., TopicItemType[] or TopicListProps["items"]) and annotate the useMemo that produces reviewItems with the ReviewList/ReviewItem prop type (e.g., ReviewItemType[] or ReviewListProps["items"]); import or reference the component prop types used by the child components, change the useMemo signatures to use those generics so the returned arrays are strongly typed, and remove the "as any" casts from the items={topicItems} and items={reviewItems} usages.src/app/groups/[id]/admin/bookcase/[meetingId]/page.tsx-37-38 (1)
37-38:⚠️ Potential issue | 🟡 MinorRemove
anycasts on params and API response properties.Lines 37–38, 60, 66 use unnecessary
anycasts that disable type safety.paramsfromuseParams()already matches the expected type fortoNumber(), and API response properties (existingTeams,clubMembers) are fully typed.Suggested fix
- const clubId = toNumber(params?.id as any); - const meetingId = toNumber(params?.meetingId as any); + const clubId = toNumber(params?.id); + const meetingId = toNumber(params?.meetingId); - const existingTeamNumbers = - data.existingTeams?.map((t: any) => Number(t.teamNumber)).filter(Number.isFinite) ?? []; + const existingTeamNumbers = + data.existingTeams?.map((t) => Number(t.teamNumber)).filter(Number.isFinite) ?? []; - data.clubMembers?.map((cm: any) => ({ + data.clubMembers?.map((cm) => ({🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@src/app/groups/`[id]/admin/bookcase/[meetingId]/page.tsx around lines 37 - 38, Remove the unnecessary any casts by passing the typed params directly to toNumber and using the typed API response properties; specifically, replace "toNumber(params?.id as any)" and "toNumber(params?.meetingId as any)" with "toNumber(params?.id)" and "toNumber(params?.meetingId)", and stop casting API response fields like existingTeams and clubMembers to any—use their declared types from the response (e.g., existingTeams, clubMembers) directly wherever they are used so type safety is preserved in the clubId, meetingId, and subsequent logic.src/hooks/queries/useClubsBookshelfQueries.ts-40-40 (1)
40-40:⚠️ Potential issue | 🟡 MinorGuard queries with positive IDs, not only finite numbers.
Number.isFinite(...)still enables0/negative IDs. Please gate with> 0to avoid accidental/clubs/0/...requests on bad params.Suggested patch
+const isValidId = (id: number) => Number.isFinite(id) && id > 0; + export function useClubsBookshelfSimpleInfiniteQuery(clubId: number) { return useInfiniteQuery({ @@ - enabled: Number.isFinite(clubId), + enabled: isValidId(clubId), }); } @@ - enabled: Number.isFinite(clubId) && Number.isFinite(meetingId), + enabled: isValidId(clubId) && isValidId(meetingId), @@ - enabled: Number.isFinite(clubId) && Number.isFinite(meetingId), + enabled: isValidId(clubId) && isValidId(meetingId), @@ - enabled: Number.isFinite(clubId) && Number.isFinite(meetingId), + enabled: isValidId(clubId) && isValidId(meetingId), @@ - enabled: Number.isFinite(clubId) && Number.isFinite(meetingId), + enabled: isValidId(clubId) && isValidId(meetingId),Also applies to: 64-64, 72-72, 92-92, 114-114
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@src/hooks/queries/useClubsBookshelfQueries.ts` at line 40, Change the query enabling checks in useClubsBookshelfQueries so they require positive numeric club IDs instead of just finite numbers: replace uses of Number.isFinite(clubId) in the query option "enabled" with a guard that ensures the id is finite and greater than 0 (e.g., Number.isFinite(clubId) && clubId > 0). Update every occurrence in this file (the enabled properties at the spots flagged) so queries like /clubs/0/... or negative IDs are not triggered when clubId is invalid.src/hooks/mutations/useClubsBookshelfMutations.ts-30-33 (1)
30-33:⚠️ Potential issue | 🟡 MinorInvalidate bookshelf detail cache after patch success.
Patch updates can affect detail view fields, but Line 30-33 currently invalidates only
editandsimple.Suggested patch
onSuccess: (_, vars) => { qc.invalidateQueries({ queryKey: bookshelfQueryKeys.edit(vars.clubId, vars.meetingId) }); qc.invalidateQueries({ queryKey: bookshelfQueryKeys.simple(vars.clubId) }); + qc.invalidateQueries({ queryKey: bookshelfQueryKeys.detail(vars.clubId, vars.meetingId) }); },🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@src/hooks/mutations/useClubsBookshelfMutations.ts` around lines 30 - 33, The onSuccess handler in useClubsBookshelfMutations.ts currently invalidates only bookshelfQueryKeys.edit and bookshelfQueryKeys.simple after a patch; also invalidate the bookshelf detail cache by calling qc.invalidateQueries with bookshelfQueryKeys.detail using the same vars (e.g., bookshelfQueryKeys.detail(vars.clubId, vars.meetingId)) so the detail view reflects patched fields; update the onSuccess block to include this additional invalidateQueries call alongside the existing ones.src/services/clubsBookshelfService.ts-133-136 (1)
133-136:⚠️ Potential issue | 🟡 MinorAvoid sending explicit
nullas query parameter value.Passing
cursorId: cursorId ?? nullmay serialize to?cursorId=nullin the URL, which the backend might not handle correctly. Prefer omitting the parameter entirely when undefined.🔧 Suggested fix
const res = await apiClient.get<TopicsResponse>( CLUBS_BOOKSHELF_ENDPOINTS.topics(clubId, meetingId), - { params: { cursorId: cursorId ?? null } } + { params: cursorId != null ? { cursorId } : {} } );The same pattern applies to
getReviewsat line 199.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@src/services/clubsBookshelfService.ts` around lines 133 - 136, The call to apiClient.get for CLUBS_BOOKSHELF_ENDPOINTS.topics (and similarly in getReviews) passes cursorId as cursorId ?? null which can serialize to ?cursorId=null; instead, only include the params.cursorId key when cursorId is defined. Modify the request construction around apiClient.get<TopicsResponse>(CLUBS_BOOKSHELF_ENDPOINTS.topics(clubId, meetingId), { params: { ... } }) to conditionally add cursorId (e.g., build params object and set params.cursorId = cursorId only if cursorId !== undefined) so the query param is omitted when undefined.
🧹 Nitpick comments (25)
src/components/base-ui/Search/search_bookresult.tsx (1)
86-100: Good interactive states; consider adding accessible label.The active/hover states provide clear tactile feedback. However, the button relies solely on an icon with
alt="", making it inaccessible to screen readers.♿ Optional: Add aria-label for accessibility
<button type="button" + aria-label="Edit" onClick={(e) => { e.stopPropagation(); onPencilClick?.(); }}🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@src/components/base-ui/Search/search_bookresult.tsx` around lines 86 - 100, The pencil icon button is inaccessible to screen readers because it uses an empty img alt; add a clear accessible label to the interactive element by adding an aria-label (e.g., aria-label="Edit book" or similar) to the button that wraps the Image so screen readers announce its purpose, and keep the Image alt as "" or set aria-hidden on the Image to avoid duplicate announcements; target the existing button element that uses onPencilClick and the Image component to implement this change.src/app/groups/[id]/layout.tsx (1)
39-79: Admin and non-admin tab definitions are now identical.Both branches define the same hrefs pointing to non-admin routes (
/groups/${groupId},/groups/${groupId}/notice,/groups/${groupId}/bookcase). Consider consolidating into a singletabsdefinition to eliminate duplication.♻️ Proposed refactor
- const tabs = isAdmin - ? [ - { - id: "home" as TabType, - label: "모임 홈", - href: `/groups/${groupId}`, - icon: "/group_home.svg", - }, - { - id: "notice" as TabType, - label: "공지사항", - href: `/groups/${groupId}/notice`, - icon: "/Notification2.svg", - }, - { - id: "bookcase" as TabType, - label: "책장", - href: `/groups/${groupId}/bookcase`, - icon: "/bookshelf.svg", - }, - ] - : [ - { - id: "home" as TabType, - label: "모임 홈", - href: `/groups/${groupId}`, - icon: "/group_home.svg", - }, - { - id: "notice" as TabType, - label: "공지사항", - href: `/groups/${groupId}/notice`, - icon: "/Notification2.svg", - }, - { - id: "bookcase" as TabType, - label: "책장", - href: `/groups/${groupId}/bookcase`, - icon: "/bookshelf.svg", - }, - ]; + const tabs = [ + { + id: "home" as TabType, + label: "모임 홈", + href: `/groups/${groupId}`, + icon: "/group_home.svg", + }, + { + id: "notice" as TabType, + label: "공지사항", + href: `/groups/${groupId}/notice`, + icon: "/Notification2.svg", + }, + { + id: "bookcase" as TabType, + label: "책장", + href: `/groups/${groupId}/bookcase`, + icon: "/bookshelf.svg", + }, + ];If the admin tabs should actually point to admin-specific routes (e.g.,
/groups/${groupId}/admin/notice), please verify and restore the differentiated behavior.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@src/app/groups/`[id]/layout.tsx around lines 39 - 79, The current conditional that builds tabs (variable tabs using isAdmin) duplicates identical arrays; consolidate by returning a single shared array instead of separate branches, e.g., remove the isAdmin conditional and assign the common array to tabs (keeping TabType casts and icons) or, if admin should have different routes, update the admin branch to use the correct admin-specific hrefs (e.g., `/groups/${groupId}/admin/...`) so the isAdmin branch purpose is preserved; adjust references to tabs, isAdmin, groupId, and TabType accordingly.src/components/base-ui/Group-Search/search_clublist/search_club_category_tags.tsx (1)
5-8: LocalCategoryDTOtype differs fromClubCategoryDTO.The local
CategoryDTOtype has optional fields (code?: string | number | null,description?: string | null), whileClubCategoryDTOinsrc/types/groups/clubsearch.tshas required non-nullable fields (code: string,description: string). Consider importing and using the canonical type for consistency.♻️ Suggested approach
+"use client"; + +import React from "react"; +import type { ClubCategoryDTO } from "@/types/groups/clubsearch"; + -type CategoryDTO = { - code?: string | number | null; - description?: string | null; -}; +// Use ClubCategoryDTO from shared types, with Partial for backward compatibility +type CategoryDTO = Partial<ClubCategoryDTO> & { code?: string | number | null };Or adjust the prop type to explicitly use
ClubCategoryDTOwhen DTOs are passed.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@src/components/base-ui/Group-Search/search_clublist/search_club_category_tags.tsx` around lines 5 - 8, Replace the local CategoryDTO type with the canonical ClubCategoryDTO type: import ClubCategoryDTO and use it in place of the locally declared CategoryDTO (or update any prop/type annotations that currently reference CategoryDTO to use ClubCategoryDTO), ensuring fields are non-optional and match the shared contract; alternatively, if DTOs might be partial here, explicitly map/transform incoming data to ClubCategoryDTO before use and update the component props to accept ClubCategoryDTO for consistency.src/types/groups/bookcasedetail.ts (1)
46-77: Duplicate type definitions across files.
TeamKey,ExistingTeamItem, andMeetingMemberInfoare also defined insrc/types/groups/meetingDetail.ts. Consider extracting shared types to a common module to avoid drift and maintenance burden.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@src/types/groups/bookcasedetail.ts` around lines 46 - 77, The types TeamKey, ExistingTeamItem, and MeetingMemberInfo are duplicated; extract these shared types into a single common types module (e.g., a new file exporting TeamKey, ExistingTeamItem, MeetingMemberInfo) and replace the local declarations in GetMeetingMembers-related code with imports from that module; update references in MeetingMemberItem and any other files that previously redefined them so they import the canonical types and remove the duplicate type declarations.src/types/groups/meetingDetail.ts (1)
3-11: Consolidate duplicate type definitions.
TeamKeyandExistingTeamItemhave identical structures. Additionally, these types are also defined insrc/types/groups/bookcasedetail.ts. Consider consolidating them in a shared location to avoid duplication and maintain consistency.♻️ Suggestion
Either:
- Export
TeamKeyfrom one file and reuse it asExistingTeamItemvia type alias, or- Create a shared types file for meeting-related types and import from both locations.
-export type TeamKey = { - teamId: number; - teamNumber: number; -}; - -export type ExistingTeamItem = { - teamId: number; - teamNumber: number; -}; +export type TeamKey = { + teamId: number; + teamNumber: number; +}; + +export type ExistingTeamItem = TeamKey;🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@src/types/groups/meetingDetail.ts` around lines 3 - 11, TeamKey and ExistingTeamItem are identical; remove the duplication by keeping a single definition (e.g., export type TeamKey = { teamId: number; teamNumber: number; }) and make ExistingTeamItem a type alias to it (type ExistingTeamItem = TeamKey) or move that single definition into a shared meeting-types module and import it where needed (update references that currently use ExistingTeamItem and TeamKey to use the shared TeamKey export).src/components/base-ui/Bookcase/bookid/BookshelfAdminMenu.tsx (2)
31-34: Consider adding delete confirmation.The delete handler immediately invokes
onDelete()without any confirmation. Destructive actions typically benefit from a confirmation step to prevent accidental deletions.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@src/components/base-ui/Bookcase/bookid/BookshelfAdminMenu.tsx` around lines 31 - 34, The delete handler handleDeleteClick in BookshelfAdminMenu currently calls onDelete() unconditionally; change it to prompt for confirmation (e.g., use a browser confirm or the app's ConfirmDialog component) and only call onDelete() and setMenuOpen(false) if the user confirms, leaving the menu open/canceling if they decline.
16-24: Optimize click-outside listener to only attach when menu is open.Unlike
ItemMoreMenu.tsxwhich conditionally attaches the listener only whenopenis true, this component always has the listener attached. Consider matching the pattern for consistency and minor performance improvement.♻️ Proposed fix
useEffect(() => { + if (!menuOpen) return; + const handleClickOutside = (e: MouseEvent) => { if (menuRef.current && !menuRef.current.contains(e.target as Node)) { setMenuOpen(false); } }; document.addEventListener("mousedown", handleClickOutside); return () => document.removeEventListener("mousedown", handleClickOutside); - }, []); + }, [menuOpen]);🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@src/components/base-ui/Bookcase/bookid/BookshelfAdminMenu.tsx` around lines 16 - 24, The click-outside listener is always attached; change the useEffect in BookshelfAdminMenu to only add the "mousedown" listener when the menu is open by watching the menu open state (e.g., menuOpen or open) in the dependency array, and remove it in the cleanup; keep the existing handleClickOutside, menuRef, and setMenuOpen(false) logic but only register document.addEventListener("mousedown", handleClickOutside) when the menu is open and ensure the cleanup removes it.src/components/base-ui/BookStory/bookstory_choosebook.tsx (2)
69-71: Consider removing empty container div.This empty div with a placeholder comment serves no apparent purpose. If it's not needed for layout spacing, consider removing it entirely.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@src/components/base-ui/BookStory/bookstory_choosebook.tsx` around lines 69 - 71, Remove the empty placeholder div in BookStoryChooseBook (the JSX element with className "shrink-0 t:ml-[52px] t:self-stretch t:flex t:items-end mt-4 t:mt-0 flex justify-center" and the comment "비워둠 (기존 UI 유지)"); if spacing is required keep a semantic placeholder (e.g., an accessible spacer or apply the spacing to a parent/container) otherwise delete the element to avoid dead DOM nodes and redundant layout classes.
22-22: Missing semicolon.Line 22 is missing a semicolon at the end of the statement, which is inconsistent with the rest of the codebase style.
🔧 Proposed fix
- const clickable = !!onButtonClick + const clickable = !!onButtonClick;🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@src/components/base-ui/BookStory/bookstory_choosebook.tsx` at line 22, Add the missing semicolon at the end of the statement that defines clickable (replace "const clickable = !!onButtonClick" with "const clickable = !!onButtonClick;") to match project style; the change should be made where the clickable constant is declared in bookstory_choosebook.tsx.src/components/base-ui/Bookcase/ItemMoreMenu.tsx (1)
47-95: Consider adding keyboard accessibility for dropdown navigation.The dropdown menu lacks keyboard navigation support. Users should be able to navigate menu items with arrow keys and close the menu with Escape.
🔧 Suggested enhancement for Escape key handling
useEffect(() => { if (!open) return; const onDown = (e: MouseEvent) => { if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false); }; + const onKeyDown = (e: KeyboardEvent) => { + if (e.key === "Escape") setOpen(false); + }; document.addEventListener("mousedown", onDown); + document.addEventListener("keydown", onKeyDown); - return () => document.removeEventListener("mousedown", onDown); + return () => { + document.removeEventListener("mousedown", onDown); + document.removeEventListener("keydown", onKeyDown); + }; }, [open]);🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@src/components/base-ui/Bookcase/ItemMoreMenu.tsx` around lines 47 - 95, The ItemMoreMenu dropdown currently rendered when open lacks keyboard navigation; update the component to add proper ARIA roles and keyboard handlers: give the menu container role="menu" and each button role="menuitem" with tabIndex={-1}, create refs for the actionable buttons (report, edit, delete) and manage focus when open (focus first item), implement an onKeyDown handler on the menu to handle ArrowDown/ArrowUp to move focus between these refs and Enter/Space to activate the focused item (call the existing handle wrapper with onReport/onEdit/onDelete), and handle Escape to close the menu (call the existing close logic that toggles open). Ensure canManage gating still applies (only include refs for present items) and maintain existing click behavior via the handle function.src/types/groups/grouphome.ts (1)
2-7: Prefer reusing the sharedApiResponsetype instead of redefining it.Duplicating the API envelope in domain files increases drift risk. Importing from the central API types module keeps response typing consistent.
💡 Suggested refactor
-// 공통 응답 포맷 -export interface ApiResponse<T> { - isSuccess: boolean; - code: string; - message: string; - result: T; -} +import type { ApiResponse } from "@/lib/api/types";🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@src/types/groups/grouphome.ts` around lines 2 - 7, Remove the local ApiResponse<T> interface duplicate and instead import and use the shared ApiResponse type from the central API types module; locate the local definition named ApiResponse<T> in this file, delete it, add an import for the shared ApiResponse symbol, and update any usages to reference that imported type so the domain file reuses the canonical API envelope.src/components/base-ui/Group/group_admin_menu.tsx (1)
44-47: Add basic menu ARIA semantics for keyboard/screen-reader clarity.
Line 44andLine 60render interactive menu UI without menu semantics (aria-expanded,aria-haspopup,role="menu"/menuitem"), which hurts accessibility discoverability.♿ Suggested patch
<button type="button" onClick={() => setMenuOpen(!menuOpen)} + aria-haspopup="menu" + aria-expanded={menuOpen} + aria-controls="group-admin-menu" className="flex items-center gap-2 hover:brightness-50 cursor-pointer" > @@ <div + id="group-admin-menu" + role="menu" className=" absolute right-0 top-full mt-2 w-34 h-[120px] @@ <button type="button" onClick={handleApplicantClick} + role="menuitem" className=" @@ <button type="button" onClick={handleMembersClick} + role="menuitem" className=" @@ <button type="button" onClick={handleEditClick} + role="menuitem" className="Also applies to: 60-70, 72-117
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@src/components/base-ui/Group/group_admin_menu.tsx` around lines 44 - 47, The menu button and menu markup in Group (group_admin_menu.tsx) lack ARIA semantics; update the toggle button (the element using onClick={() => setMenuOpen(!menuOpen)}) to include aria-haspopup="true", aria-expanded={menuOpen} and aria-controls referencing the menu container ID, and add role="menu" to the menu container and role="menuitem" (or role="menuitemcheckbox"/"menuitemradio" as appropriate) to each menu entry; ensure keyboard interaction is supported by preserving/adding onKeyDown handlers that open/close the menu with Enter/Space/Escape and move focus into the role="menu" so screen readers and keyboard users can discover and navigate the menu.src/hooks/mutations/useClubAdminEditMutations.ts (1)
7-11: Remove commented fallback code before merge.
Line 7-11andLine 18leave implementation alternatives in production code; this adds noise and future drift risk.🧹 Suggested cleanup
-// ✅ clubService가 객체 export인 경우 import { clubService } from "@/services/clubService"; -// ✅ 함수 export면 이렇게 바꿔: -// import { updateAdminClub } from "@/services/clubService"; @@ return useMutation({ mutationFn: (body: UpdateClubAdminRequest) => clubService.updateAdminClub(clubId, body), - // mutationFn: (body: UpdateClubAdminRequest) => updateAdminClub(clubId, body), onSuccess: () => {Also applies to: 18-18
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@src/hooks/mutations/useClubAdminEditMutations.ts` around lines 7 - 11, Remove the leftover commented fallback imports/alternatives in useClubAdminEditMutations.ts (the commented block suggesting switching between import { clubService } and import { updateAdminClub }) and the similar commented alternative later in the file; keep only the actual import/usage you intend (e.g., import { clubService } from "@/services/clubService" or the named function import updateAdminClub) and delete the commented lines so production code contains no commented implementation alternatives.src/hooks/queries/useClubAdminEditQueries.ts (1)
13-13: Remove commented-out code.The commented alternative
queryFnline is dead code. If it's no longer needed, remove it to keep the codebase clean.🧹 Suggested cleanup
queryKey: clubAdminEditQueryKeys.detail(clubId), queryFn: () => clubService.getAdminClubDetail(clubId), - // queryFn: () => getAdminClubDetail(clubId), enabled: Number.isFinite(clubId) && clubId > 0,🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@src/hooks/queries/useClubAdminEditQueries.ts` at line 13, Remove the dead commented-out alternative queryFn line in useClubAdminEditQueries.ts; specifically delete the "// queryFn: () => getAdminClubDetail(clubId)," comment so only the active query configuration remains (referencing the query setup and getAdminClubDetail usage) to keep the code clean.src/app/groups/[id]/admin/applicant/page.tsx (2)
89-96: Consider extractingformatYYYYMMDDto a shared utility.This date formatting function is likely useful elsewhere. Consider moving it to a shared utilities module.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@src/app/groups/`[id]/admin/applicant/page.tsx around lines 89 - 96, Extract the formatYYYYMMDD function into a shared utility module (e.g., create a new exported function in a utilities/date or utils/format.ts file), replace the local definition in src/app/groups/[id]/admin/applicant/page.tsx with an import of that exported function, update any other files that need the same formatting to import the shared utility, and ensure the new module is exported with appropriate TypeScript signatures and unit tests or runtime checks as needed to preserve the original behavior (including the NaN fallback '0000.00.00').
177-187: Review the eslint-disable for exhaustive-deps.The
eslint-disablecomment suggests the dependency array may be incomplete. The effect accessesmembersQuery.fetchNextPagebut only lists specific properties. This is intentional to prevent infinite re-triggers, but consider using a ref pattern or restructuring to avoid the disable.💡 Alternative using ref
+ const fetchNextPageRef = useRef(membersQuery.fetchNextPage); + fetchNextPageRef.current = membersQuery.fetchNextPage; + useEffect(() => { if (!membersQuery.hasNextPage) return; if (membersQuery.isFetchingNextPage) return; const needCount = currentPage * itemsPerPage; if (applicants.length < needCount) { - membersQuery.fetchNextPage(); + fetchNextPageRef.current(); } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [currentPage, applicants.length, membersQuery.hasNextPage, membersQuery.isFetchingNextPage]); + }, [currentPage, applicants.length, membersQuery.hasNextPage, membersQuery.isFetchingNextPage]);🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@src/app/groups/`[id]/admin/applicant/page.tsx around lines 177 - 187, The effect currently disables exhaustive-deps while referencing membersQuery.fetchNextPage directly; replace the eslint-disable by stabilizing the fetch function via a ref or memo so you can safely include all needed deps: create a ref (e.g., fetchNextPageRef) and assign membersQuery.fetchNextPage to it inside a useEffect that watches membersQuery, then in the main useEffect call fetchNextPageRef.current(), and list currentPage, itemsPerPage, applicants.length, membersQuery.hasNextPage, membersQuery.isFetchingNextPage as dependencies; this removes the eslint-disable while preventing infinite re-renders caused by unstable membersQuery.fetchNextPage.src/app/groups/[id]/bookcase/page.tsx (1)
112-154: JSX has inconsistent indentation.The return statement's JSX has unbalanced indentation that makes the code harder to read.
🧹 Formatting suggestion
return ( - <div - className="..." - > - {list.map((item) => { + <div className="..."> + {list.map((item) => { // ... - })} - {isStaff && ( - <FloatingFab ... /> - )} - </div> - ); + })} + {isStaff && ( + <FloatingFab ... /> + )} + </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 112 - 154, The JSX returned from the component has inconsistent and unbalanced indentation around the grid container, the list.map rendering (BookcaseCard), and the conditional FloatingFab; normalize indentation for readability by aligning the opening <div> with its closing </div>, indenting the {list.map((item) => { ... })} block and its returned <BookcaseCard ... /> consistently, and ensuring the {isStaff && ( <FloatingFab ... /> )} block matches the same nesting level; update surrounding lines referencing BookcaseCard, handleGoToDetail, and FloatingFab so their props and callbacks are vertically aligned and the entire return block uses a consistent two- or four-space indent style.src/hooks/queries/useClubMemberQueries.ts (1)
1-1: Remove unnecessary"use client"directive.This hooks module doesn't use browser-only APIs directly. The directive is only needed in components that use client-side features. Hooks that call
useInfiniteQuerywill work correctly when imported into client components without this directive.🧹 Suggested cleanup
-"use client"; - // src/hooks/queries/useClubMemberQueries.ts import { useInfiniteQuery, type InfiniteData } from "@tanstack/react-query";🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@src/hooks/queries/useClubMemberQueries.ts` at line 1, Remove the unnecessary "use client" directive at the top of this module: delete the `"use client"` line in the useClubMemberQueries module so it no longer forces client-only execution; keep the hook implementations (e.g., useClubMemberQueries and any calls to useInfiniteQuery) as-is so they can be imported into client components without the directive.src/components/base-ui/Bookcase/bookid/BookshelfDeleteConfirmModal.tsx (1)
31-46: Consider memoizingonClosein parent or using a ref.The
onClosecallback is in theuseEffectdependency array. If the parent doesn't memoizeonClosewithuseCallback, this effect will re-run on every parent render, potentially causing unnecessary event listener churn.💡 Alternative using ref to avoid dependency
+"use client"; + +import { useEffect, useRef } from "react"; +import Image from "next/image"; + +// ... + export default function BookshelfDeleteConfirmModal({ // ... }: Props) { + const onCloseRef = useRef(onClose); + onCloseRef.current = onClose; + useEffect(() => { if (!isOpen) return; const prevOverflow = document.body.style.overflow; document.body.style.overflow = "hidden"; const onKeyDown = (e: KeyboardEvent) => { - if (e.key === "Escape") onClose(); + if (e.key === "Escape") onCloseRef.current(); }; window.addEventListener("keydown", onKeyDown); return () => { document.body.style.overflow = prevOverflow; window.removeEventListener("keydown", onKeyDown); }; - }, [isOpen, onClose]); + }, [isOpen]);🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@src/components/base-ui/Bookcase/bookid/BookshelfDeleteConfirmModal.tsx` around lines 31 - 46, The effect in BookshelfDeleteConfirmModal re-runs whenever the onClose prop identity changes; either memoize the parent's onClose with useCallback, or change this component to use a ref for the latest onClose (e.g., create an onCloseRef and update it on prop change) and have the useEffect (the one that registers window keydown and toggles body overflow) depend only on isOpen; inside the handler call onCloseRef.current() so the effect no longer needs onClose in its dependency array.src/lib/api/endpoints/ClubsBookshelf.ts (1)
5-14: Consider consolidating duplicate endpoints and using consistent naming.Several endpoints return identical URLs:
simpleBookshelvesandCREATE_BOOKSHELF→ same URLdetail,delete, andpatch→ same URLAdditionally, naming is inconsistent (
SEARCH_BOOKS/CREATE_BOOKSHELFvs. camelCase for others).While semantic separation can aid readability, consider either:
- Consolidating to single endpoints and letting the HTTP method distinguish intent
- Using consistent naming throughout (all camelCase recommended)
♻️ Example consolidation
export const CLUBS_BOOKSHELF_ENDPOINTS = { - simpleBookshelves: (clubId: number | string) => `${API_BASE_URL}/clubs/${clubId}/bookshelves`, - - SEARCH_BOOKS: `${API_BASE_URL}/books/search`, - CREATE_BOOKSHELF: (clubId: number | string) =>`${API_BASE_URL}/clubs/${clubId}/bookshelves`, + bookshelves: (clubId: number | string) => `${API_BASE_URL}/clubs/${clubId}/bookshelves`, + searchBooks: `${API_BASE_URL}/books/search`, - // 책장 상세, 삭제, 수정 - detail: (clubId: number | string, meetingId: number | string) => `${API_BASE_URL}/clubs/${clubId}/bookshelves/${meetingId}`, - delete: (clubId: number | string, meetingId: number | string) => `${API_BASE_URL}/clubs/${clubId}/bookshelves/${meetingId}`, editGet: (clubId: number | string, meetingId: number | string) => `${API_BASE_URL}/clubs/${clubId}/bookshelves/${meetingId}/edit`, - patch: (clubId: number | string, meetingId: number | string) =>`${API_BASE_URL}/clubs/${clubId}/bookshelves/${meetingId}`, + bookshelf: (clubId: number | string, meetingId: number | string) => `${API_BASE_URL}/clubs/${clubId}/bookshelves/${meetingId}`,🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@src/lib/api/endpoints/ClubsBookshelf.ts` around lines 5 - 14, The endpoints object contains duplicate URL builders and inconsistent naming; consolidate identical routes and standardize naming to camelCase: remove or merge simpleBookshelves into createBookshelf (or vice versa) so CREATE_BOOKSHELF is not duplicated, and merge detail, delete, and patch into a single bookshelve (or bookshelfDetail) function that returns `${API_BASE_URL}/clubs/${clubId}/bookshelves/${meetingId}` and let the caller use HTTP verbs to distinguish actions; update references that use SEARCH_BOOKS, CREATE_BOOKSHELF, simpleBookshelves, detail, delete, editGet, and patch to the new keys (e.g., searchBooks, createBookshelf, bookshelfDetail, editGet) to keep naming consistent.src/services/clubMemberService.ts (1)
16-21:cursorId ?? nullis redundant; simplify to justcursorId.The apiClient automatically filters out
undefinedandnullvalues from query parameters before sending requests (see the query string builder logic). PassingcursorId: nullhas the same effect as passingcursorId: undefined—both will be omitted from the final URL. Simplify tocursorIdfor clarity and consistency with other services likestoryService.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@src/services/clubMemberService.ts` around lines 16 - 21, In the apiClient.get call that fetches club members (the const res = await apiClient.get<ApiResponse<GetClubMembersResult>>(CLUBS.members(clubId), { params: { status, cursorId: cursorId ?? null, }, });), remove the redundant null coalescing and just pass cursorId (params: { status, cursorId }). This aligns with the query string builder behavior and other services (e.g., storyService) so undefined/null values are omitted consistently.src/components/base-ui/Group/DebateList.tsx (1)
31-199: Consider extracting shared editable-item list logic withReviewList.
DebateListandReviewListnow have near-identical edit/delete/menu orchestration. A shared base component/hook would reduce drift and bug-fix duplication.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@src/components/base-ui/Group/DebateList.tsx` around lines 31 - 199, DebateList duplicates edit/delete/menu orchestration that ReviewList shares; extract that logic into a shared hook or base component (e.g., useEditableList or EditableListBase) which owns state and helpers: editingId, draftText, deleteTargetId, editingItem (derived), startEdit, cancelEdit, openDelete, closeDelete, confirmDelete and the Escape key useEffect; keep UI-specific pieces (rendering list, ItemMoreMenu, LongtermChatInput, onReport/onUpdate/onDelete/onClickAuthor handlers) in DebateList and ReviewList but call the shared hook/component with props {items, isStaff, onReport, onUpdate, onDelete, onClickAuthor} so both files reuse the same orchestration and reduce duplication.src/hooks/mutations/useClubsBookshelfMutations.ts (1)
19-28:usePatchBookshelfMutationparameters are redundant withmutationFnargs.The hook takes
(clubId, meetingId)but usesargs.clubId/args.meetingIdinstead. This API is easy to misuse and can be simplified.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@src/hooks/mutations/useClubsBookshelfMutations.ts` around lines 19 - 28, The hook currently takes (clubId, meetingId) but the mutationFn expects an args object with its own clubId/meetingId — make the API consistent by using the hook parameters and simplifying mutationFn: change mutationFn in usePatchBookshelfMutation to accept only the body (BookshelfPatchRequest) and call clubsBookshelfService.patch(clubId, meetingId, body) using the hook’s clubId and meetingId; also update the mutationKey to include the clubId/meetingId if needed and adjust types so callers call mutate(body) rather than passing duplicate club/meeting ids.src/services/clubsBookshelfService.ts (2)
118-120: Remove leftover Korean comment.The inline comment
// 또는 detail/patch 경로 재사용appears to be a development note that should be removed before merging.🧹 Suggested fix
const res = await apiClient.delete<DeleteBookshelfResponse>( - CLUBS_BOOKSHELF_ENDPOINTS.delete(clubId, meetingId) // 또는 detail/patch 경로 재사용 + CLUBS_BOOKSHELF_ENDPOINTS.delete(clubId, meetingId) );🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@src/services/clubsBookshelfService.ts` around lines 118 - 120, Remove the leftover Korean inline comment after the endpoint call in src/services/clubsBookshelfService.ts: delete the text "// 또는 detail/patch 경로 재사용" from the apiClient.delete<DeleteBookshelfResponse>(CLUBS_BOOKSHELF_ENDPOINTS.delete(clubId, meetingId)) line so the call remains clean (refer to the apiClient.delete invocation and CLUBS_BOOKSHELF_ENDPOINTS.delete symbol to locate the code).
61-96: Consider consistent parameter style across methods.The service mixes parameter styles:
createBookshelfuses an object{ clubId, body }, whilegetEditandpatchuse positional arguments. Also, line 93 explicitly setsContent-Type: application/jsonforpatch, butcreateBookshelfdoesn't. Either this header is needed for all JSON body requests, orapiClienthandles it automatically (making line 93 redundant).♻️ Suggested: Align parameter style with other methods
- getEdit: async ( - clubId: number, - meetingId: number - ): Promise<BookshelfEditGetResult> => { + getEdit: async (params: { + clubId: number; + meetingId: number; + }): Promise<BookshelfEditGetResult> => { + const { clubId, meetingId } = params; const res = await apiClient.get<BookshelfEditGetResponse>( CLUBS_BOOKSHELF_ENDPOINTS.editGet(clubId, meetingId) ); return res.result; }, - patch: async ( - clubId: number, - meetingId: number, - payload: BookshelfPatchRequest - ): Promise<BookshelfPatchResult> => { + patch: async (params: { + clubId: number; + meetingId: number; + payload: BookshelfPatchRequest; + }): Promise<BookshelfPatchResult> => { + const { clubId, meetingId, payload } = params; const res = await apiClient.patch<BookshelfPatchResponse>( CLUBS_BOOKSHELF_ENDPOINTS.patch(clubId, meetingId), - payload, - { headers: { "Content-Type": "application/json" } } + payload ); return res.result; },🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@src/services/clubsBookshelfService.ts` around lines 61 - 96, createBookshelf currently accepts an object parameter ({ clubId, body }) while getEdit and patch use positional args (clubId, meetingId, payload), and patch sets an explicit Content-Type header that createBookshelf does not; make the parameter style consistent across the service (either change createBookshelf to createBookshelf(clubId: number, body: CreateBookshelfRequest) or change getEdit/patch to accept objects) and propagate that change to callers, and unify Content-Type handling by either removing the explicit header from patch if apiClient already sets JSON headers or adding the same header to createBookshelf's post call; adjust the implementations that call CLUBS_BOOKSHELF_ENDPOINTS.CREATE_BOOKSHELF, .editGet and .patch and the apiClient.post/patch usages accordingly.
ℹ️ Review info
Configuration used: defaults
Review profile: CHILL
Plan: Pro
⛔ Files ignored due to path filters (3)
public/Danger_Circle.svgis excluded by!**/*.svgpublic/Delete_2.svgis excluded by!**/*.svgpublic/Edit.svgis excluded by!**/*.svg
📒 Files selected for processing (57)
.env.examplesrc/app/groups/[id]/admin/applicant/page.tsxsrc/app/groups/[id]/admin/bookcase/[meetingId]/dummy.tssrc/app/groups/[id]/admin/bookcase/[meetingId]/edit/layout.tsxsrc/app/groups/[id]/admin/bookcase/[meetingId]/edit/page.tsxsrc/app/groups/[id]/admin/bookcase/[meetingId]/page.tsxsrc/app/groups/[id]/admin/bookcase/new/page.tsxsrc/app/groups/[id]/admin/edit/layout.tsxsrc/app/groups/[id]/admin/edit/page.tsxsrc/app/groups/[id]/admin/members/page.tsxsrc/app/groups/[id]/bookcase/[bookId]/DebateSection.tsxsrc/app/groups/[id]/bookcase/[bookId]/MeetingTabSection.tsxsrc/app/groups/[id]/bookcase/[bookId]/ReviewSection.tsxsrc/app/groups/[id]/bookcase/[bookId]/dummy.tssrc/app/groups/[id]/bookcase/[bookId]/page.tsxsrc/app/groups/[id]/bookcase/page.tsxsrc/app/groups/[id]/dummy.tssrc/app/groups/[id]/layout.tsxsrc/app/groups/[id]/page.tsxsrc/app/groups/page.tsxsrc/components/base-ui/BookStory/bookstory_choosebook.tsxsrc/components/base-ui/Bookcase/BookDetailCard.tsxsrc/components/base-ui/Bookcase/BookcaseCard.tsxsrc/components/base-ui/Bookcase/ItemMoreMenu.tsxsrc/components/base-ui/Bookcase/bookid/BookshelfAdminMenu.tsxsrc/components/base-ui/Bookcase/bookid/BookshelfDeleteConfirmModal.tsxsrc/components/base-ui/Bookcase/bookid/ReviewList.tsxsrc/components/base-ui/Group-Search/search_clublist/search_club_category_tags.tsxsrc/components/base-ui/Group-Search/search_clublist/search_clublist_item.tsxsrc/components/base-ui/Group/DebateList.tsxsrc/components/base-ui/Group/group_admin_menu.tsxsrc/components/base-ui/LongtermInput.tsxsrc/components/base-ui/Search/search_bookresult.tsxsrc/components/layout/BookSelectModal.tsxsrc/hooks/mutations/useClubAdminEditMutations.tssrc/hooks/mutations/useClubMemberMutations.tssrc/hooks/mutations/useClubsBookshelfMutations.tssrc/hooks/mutations/useMeetingMutations.tssrc/hooks/queries/useClubAdminEditQueries.tssrc/hooks/queries/useClubMemberQueries.tssrc/hooks/queries/useClubhomeQueries.tssrc/hooks/queries/useClubsBookshelfQueries.tssrc/hooks/queries/useMeetingQueries.tssrc/lib/api/endpoints/Clubs.tssrc/lib/api/endpoints/ClubsBookshelf.tssrc/lib/api/endpoints/index.tssrc/services/clubMemberService.tssrc/services/clubService.tssrc/services/clubsBookshelfService.tssrc/services/meetingService.tssrc/types/bookshelf.tssrc/types/groups/bookcasedetail.tssrc/types/groups/bookcasehome.tssrc/types/groups/clubAdminEdit.tssrc/types/groups/clubMembers.tssrc/types/groups/grouphome.tssrc/types/groups/meetingDetail.ts
💤 Files with no reviewable changes (5)
- src/app/groups/[id]/admin/bookcase/[meetingId]/dummy.ts
- src/app/groups/[id]/dummy.ts
- src/app/groups/[id]/bookcase/[bookId]/dummy.ts
- src/types/groups/bookcasehome.ts
- .env.example
| const handleSelectAction = async (clubMemberId: number, action: ActionType) => { | ||
| // action 매핑: | ||
| // delete -> REJECT (PENDING 삭제) | ||
| // approve -> APPROVE (PENDING -> MEMBER) | ||
| try { | ||
| if (action === 'delete') { | ||
| await updateStatus({ | ||
| clubId, | ||
| clubMemberId, | ||
| body: { | ||
| command: 'REJECT' | ||
| }, | ||
| }); | ||
| } else { | ||
| await updateStatus({ | ||
| clubId, | ||
| clubMemberId, | ||
| body: { | ||
| command: 'APPROVE', | ||
| }, | ||
| }); | ||
| } | ||
| } finally { | ||
| setOpenMenuId(null); | ||
| } | ||
| setOpenMenuId(null); | ||
| }; |
There was a problem hiding this comment.
Add error handling for mutation failures.
The try/finally block doesn't catch errors, so mutation failures are silently ignored. Users won't know if their approve/reject action failed.
🔧 Proposed fix
const handleSelectAction = async (clubMemberId: number, action: ActionType) => {
try {
if (action === 'delete') {
await updateStatus({
clubId,
clubMemberId,
body: { command: 'REJECT' },
});
} else {
await updateStatus({
clubId,
clubMemberId,
body: { command: 'APPROVE' },
});
}
+ } catch (err) {
+ const message = action === 'delete' ? '거절 처리에 실패했습니다.' : '가입 처리에 실패했습니다.';
+ // Consider using toast or alert here
+ console.error(message, err);
+ // toast.error(message); // if using react-hot-toast
} finally {
setOpenMenuId(null);
}
};📝 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 handleSelectAction = async (clubMemberId: number, action: ActionType) => { | |
| // action 매핑: | |
| // delete -> REJECT (PENDING 삭제) | |
| // approve -> APPROVE (PENDING -> MEMBER) | |
| try { | |
| if (action === 'delete') { | |
| await updateStatus({ | |
| clubId, | |
| clubMemberId, | |
| body: { | |
| command: 'REJECT' | |
| }, | |
| }); | |
| } else { | |
| await updateStatus({ | |
| clubId, | |
| clubMemberId, | |
| body: { | |
| command: 'APPROVE', | |
| }, | |
| }); | |
| } | |
| } finally { | |
| setOpenMenuId(null); | |
| } | |
| setOpenMenuId(null); | |
| }; | |
| const handleSelectAction = async (clubMemberId: number, action: ActionType) => { | |
| // action 매핑: | |
| // delete -> REJECT (PENDING 삭제) | |
| // approve -> APPROVE (PENDING -> MEMBER) | |
| try { | |
| if (action === 'delete') { | |
| await updateStatus({ | |
| clubId, | |
| clubMemberId, | |
| body: { | |
| command: 'REJECT' | |
| }, | |
| }); | |
| } else { | |
| await updateStatus({ | |
| clubId, | |
| clubMemberId, | |
| body: { | |
| command: 'APPROVE', | |
| }, | |
| }); | |
| } | |
| } catch (err) { | |
| const message = action === 'delete' ? '거절 처리에 실패했습니다.' : '가입 처리에 실패했습니다.'; | |
| // Consider using toast or alert here | |
| console.error(message, err); | |
| // toast.error(message); // if using react-hot-toast | |
| } finally { | |
| setOpenMenuId(null); | |
| } | |
| }; |
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@src/app/groups/`[id]/admin/applicant/page.tsx around lines 198 - 223, The
handler handleSelectAction currently uses try/finally so any errors from
updateStatus are swallowed; change it to try/catch/finally: in the catch block
capture the thrown error from updateStatus (both REJECT and APPROVE branches),
log/record it (console.error or processLogger), and surface UI feedback (e.g.,
call your existing toast/notification helper or set an error state) so the user
is informed of the failure; keep setOpenMenuId(null) in finally so the menu
still closes, and consider rethrowing the error only if callers need to handle
it.
| const { data: editData, isLoading } = useBookshelfEditQuery(clubId, meetingId); | ||
| const patchMutation = usePatchBookshelfMutation(clubId, meetingId); |
There was a problem hiding this comment.
Handle edit-query failure explicitly (not just loading).
When fetch fails, the page currently renders a mostly empty form instead of an error state, which is confusing and blocks progress without clear feedback.
💡 Suggested fix
- const { data: editData, isLoading } = useBookshelfEditQuery(clubId, meetingId);
+ const { data: editData, isLoading, isError } = useBookshelfEditQuery(clubId, meetingId);
...
if (isLoading) {
return <div className="w-full px-4 py-6 text-Gray-7 body_1_3">로딩중...</div>;
}
+ if (isError || !editData) {
+ return <div className="w-full px-4 py-6 text-Red-500 body_1_3">수정 정보를 불러오지 못했습니다.</div>;
+ }Also applies to: 222-224
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@src/app/groups/`[id]/admin/bookcase/[meetingId]/edit/page.tsx around lines
116 - 117, The page currently only checks isLoading from useBookshelfEditQuery
(const { data: editData, isLoading } = useBookshelfEditQuery(clubId, meetingId))
and renders the edit form even when the fetch errored; update the component to
also read and handle the query error (e.g., const { data, isLoading, error } =
useBookshelfEditQuery(...)) and render a clear error state (message/UI) when
error is present, rather than the form, and apply the same guard wherever the
edit query is used (the other occurrence around the code referenced at lines
222-224) so the UI shows loading, success, or explicit error.
| const handleSubmit = async () => { | ||
| // PUT payload 만들기 | ||
| if (!Number.isFinite(clubId) || !Number.isFinite(meetingId)) return; | ||
|
|
||
| const body: TeamMemberListPutBody = { | ||
| teamMemberList: teams.map((teamNumber) => ({ | ||
| teamNumber, | ||
| clubMemberIds: members | ||
| .filter((m) => m.teamNumber === teamNumber) | ||
| .map((m) => m.clubMemberId), | ||
| })), | ||
| teamMemberList: teams | ||
| .sort((a, b) => a - b) | ||
| .map((teamNumber) => ({ | ||
| teamNumber, | ||
| clubMemberIds: members | ||
| .filter((m) => m.teamNumber === teamNumber) | ||
| .map((m) => m.clubMemberId), | ||
| })), | ||
| }; | ||
|
|
||
| // TODO(API 연동): | ||
| // await fetch(`/api/groups/${groupId}/admin/bookcase/${meetingId}`, { method: 'PUT', body: JSON.stringify(body) }) | ||
| console.log("PUT payload:", body); | ||
| await updateTeams({ clubId, meetingId, body }); | ||
| }; |
There was a problem hiding this comment.
Block save until initial member/team data is ready.
MemberPool can trigger save even while data is still loading or after fetch error. In that state, default local state (teams=[1], members=[]) can produce an empty assignment payload and overwrite server data.
💡 Suggested guard
const handleSubmit = async () => {
+ if (!isInitialized || isLoading || isError) {
+ return;
+ }
if (!Number.isFinite(clubId) || !Number.isFinite(meetingId)) return;Also applies to: 229-244
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@src/app/groups/`[id]/admin/bookcase/[meetingId]/page.tsx around lines 120 -
135, handleSubmit can run while initial team/member data is still loading and
thus send the default local state (teams=[1], members=[]) that overwrites server
data; add a guard in handleSubmit to return early unless initial data has
finished loading and there was no fetch error. Concretely, use the same
readiness flags used by MemberPool (e.g. isLoading / isError /
isInitialLoadComplete) or add a boolean like initialDataReady and check it at
the start of handleSubmit (before building TeamMemberListPutBody and calling
updateTeams), and apply the same guard to the other submit path mentioned (lines
~229-244) so updateTeams is only called when members and teams reflect loaded
server state.
| teamMemberList: teams | ||
| .sort((a, b) => a - b) | ||
| .map((teamNumber) => ({ |
There was a problem hiding this comment.
🧩 Analysis chain
🏁 Script executed:
cat -n src/app/groups/[id]/admin/bookcase/[meetingId]/page.tsx | head -150 | tail -50Repository: checkmo2025/FE
Length of output: 1824
🏁 Script executed:
head -100 src/app/groups/[id]/admin/bookcase/[meetingId]/page.tsxRepository: checkmo2025/FE
Length of output: 3301
Use a copied array before sorting to avoid mutating React state.
teams.sort(...) mutates the state array directly. Arrays in JavaScript are reference types, and .sort() modifies the array in place. This violates React's principle of immutable state updates.
Suggested fix
- teamMemberList: teams
- .sort((a, b) => a - b)
+ teamMemberList: [...teams]
+ .sort((a, b) => a - b)
.map((teamNumber) => ({📝 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.
| teamMemberList: teams | |
| .sort((a, b) => a - b) | |
| .map((teamNumber) => ({ | |
| teamMemberList: [...teams] | |
| .sort((a, b) => a - b) | |
| .map((teamNumber) => ({ |
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@src/app/groups/`[id]/admin/bookcase/[meetingId]/page.tsx around lines 124 -
126, The code is directly calling teams.sort(...) which mutates the original
teams array (React state); to fix, create a shallow copy before sorting—e.g.,
use [...teams] or teams.slice()—and call .sort(...) on that copy, then .map(...)
to build teamMemberList; update the expression that constructs teamMemberList
(the teams.sort(...).map(...) chain) to operate on the copied array to avoid
mutating teams.
| onSubmit={handleSubmit} | ||
| /> |
There was a problem hiding this comment.
🧩 Analysis chain
🏁 Script executed:
find . -type f -name "page.tsx" | grep -E "groups.*bookcase.*meetingId"Repository: checkmo2025/FE
Length of output: 178
🏁 Script executed:
find . -type f -name "MemberPool.tsx"Repository: checkmo2025/FE
Length of output: 131
🏁 Script executed:
wc -l ./src/app/groups/[id]/admin/bookcase/[meetingId]/page.tsxRepository: checkmo2025/FE
Length of output: 119
🏁 Script executed:
sed -n '220,250p' ./src/app/groups/[id]/admin/bookcase/[meetingId]/page.tsx | cat -nRepository: checkmo2025/FE
Length of output: 1597
🏁 Script executed:
rg "handleSubmit\s*=" ./src/app/groups/[id]/admin/bookcase/[meetingId]/page.tsx -A 5Repository: checkmo2025/FE
Length of output: 269
🏁 Script executed:
head -n 200 ./src/app/groups/[id]/admin/bookcase/[meetingId]/page.tsx | cat -nRepository: checkmo2025/FE
Length of output: 7480
🏁 Script executed:
wc -l ./src/components/base-ui/Bookcase/Admin/bookdetailgrouping/MemberPool.tsxRepository: checkmo2025/FE
Length of output: 135
🏁 Script executed:
cat -n ./src/components/base-ui/Bookcase/Admin/bookdetailgrouping/MemberPool.tsxRepository: checkmo2025/FE
Length of output: 7358
Await onSubmit before navigating away.
The onSubmit callback is async and awaits an API call to update team assignments, but in MemberPool.tsx line 100 it's invoked without awaiting, causing router.back() to execute immediately. If the update fails, the user is navigated away without seeing the error.
Update handleConfirmYes to await the submission:
const handleConfirmYes = async () => {
await onSubmit();
setIsConfirmOpen(false);
router.back();
};Also update the onSubmit prop type from () => void to () => Promise<void> to match the actual async function signature.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@src/app/groups/`[id]/admin/bookcase/[meetingId]/page.tsx around lines 236 -
237, The confirm handler currently calls the async onSubmit without awaiting,
causing router.back() to run before the API update completes; update the
MemberPool component's handleConfirmYes to be async and await onSubmit() before
calling setIsConfirmOpen(false) and router.back(), and update the onSubmit prop
type/signature from () => void to () => Promise<void> (where onSubmit is
declared/used) so callers and TypeScript reflect the async behavior.
| if (!Number.isFinite(groupId) || groupId <= 0) { | ||
| return ( | ||
| <main className="w-full"> | ||
| <div className="t:mx-auto d:mx-0 w-full max-w-[1024px] t:px-3 d:px-0"> | ||
| 잘못된 모임 ID | ||
| </div> | ||
| </main> | ||
| ); | ||
| } |
There was a problem hiding this comment.
🧩 Analysis chain
🏁 Script executed:
cat -n src/app/groups/[id]/page.tsx | head -150Repository: checkmo2025/FE
Length of output: 5712
Move useMemo above early returns to preserve hook order.
useMemo is currently skipped on some renders (invalid groupId, loading, error states), then executed on others. This violates React's Rules of Hooks and can crash at runtime with "Rendered fewer hooks than expected" or similar errors.
💡 Suggested structure
- if (!Number.isFinite(groupId) || groupId <= 0) {
- return (...);
- }
-
- if (isLoading) {
- return (...);
- }
-
- if (isError) {
- return (...);
- }
-
- const me = meQuery.data!;
- const home = homeQuery.data!;
+ const me = meQuery.data;
+ const home = homeQuery.data;
+
+ const modalLinks = useMemo(() => {
+ const list = home?.links ?? [];
+ return list
+ .map((x, idx) => {
+ const raw = (x.link ?? "").trim();
+ if (!raw) return null;
+ const url = /^(https?:\/\/)/i.test(raw) ? raw : `http://${raw}`;
+ const label = (x.label ?? "").trim() || `링크 ${idx + 1}`;
+ return { id: `${idx}`, url, label };
+ })
+ .filter(Boolean) as { id: string; url: string; label: string }[];
+ }, [home?.links]);
+
+ if (!Number.isFinite(groupId) || groupId <= 0) return (...);
+ if (isLoading) return (...);
+ if (isError || !me || !home) return (...);Also applies to: 41-59, 51-59
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@src/app/groups/`[id]/page.tsx around lines 31 - 39, The useMemo call is being
conditionally skipped by early returns (invalid groupId / loading / error),
violating React hook order; move the useMemo invocation(s) that compute memoized
values for groupId (and any related memoized values referenced in lines 41-59,
51-59) to the top of the component body—above any returns—so hooks always run in
the same order; compute derived values (e.g., parsed groupId or memoized props)
using useMemo immediately after you read params and before the
invalid-groupId/loading/error conditional blocks, and then use those memoized
values in the existing early-return branches.
| setAppliedParams({ | ||
| outputFilter: mapCategoryToOutputFilter(category), | ||
| inputFilter: mapInputFilter(group, region), | ||
| keyword, | ||
| }); | ||
|
|
||
| refetchSearch(); | ||
| }; |
There was a problem hiding this comment.
setAppliedParams followed by immediate refetchSearch() can fetch with stale params.
Because state updates are async, this can trigger an extra request on the previous query state/key. Let the query run from the updated key/enabled state instead of manually refetching here.
💡 Suggested fix
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.
| setAppliedParams({ | |
| outputFilter: mapCategoryToOutputFilter(category), | |
| inputFilter: mapInputFilter(group, region), | |
| keyword, | |
| }); | |
| refetchSearch(); | |
| }; | |
| 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 183 - 190, The immediate call to
refetchSearch() after setAppliedParams(...) risks using stale params because
React state updates are async; remove the direct refetchSearch() invocation and
let the search query re-run from the updated query key/enabled state driven by
appliedParams (which are computed from mapCategoryToOutputFilter(category),
mapInputFilter(group, region), and keyword). If an immediate fetch is truly
required, trigger it from a useEffect that watches appliedParams (or invoke
refetchSearch from the setAppliedParams state-update completion path), but the
simplest fix is to delete the refetchSearch() call so the query reacts to the
new appliedParams instead.
| const openLinkNewTab = (url?: string) => { | ||
| if (!url) return; | ||
| window.open(url, "_blank", "noopener,noreferrer"); | ||
| }; | ||
|
|
There was a problem hiding this comment.
🧩 Analysis chain
🏁 Script executed:
# First, let's examine the BookSelectModal.tsx file at the specified lines
head -n 85 src/components/layout/BookSelectModal.tsx | tail -n +75 | cat -nRepository: checkmo2025/FE
Length of output: 429
🏁 Script executed:
# Check lines around 160-162 for the callsite
head -n 165 src/components/layout/BookSelectModal.tsx | tail -n +155 | cat -nRepository: checkmo2025/FE
Length of output: 668
🏁 Script executed:
# Search for all window.open calls in the codebase
rg -nP '\bwindow\.open\s*\(' -C 3Repository: checkmo2025/FE
Length of output: 1010
🏁 Script executed:
# Check if there are any other URL handling patterns in the file
rg -n "href|url|link" src/components/layout/BookSelectModal.tsx | head -20Repository: checkmo2025/FE
Length of output: 305
🏁 Script executed:
# Get more context on the app/groups/[id]/page.tsx window.open call
head -n 360 src/app/groups/[id]/page.tsx | tail -n +350 | cat -nRepository: checkmo2025/FE
Length of output: 540
Harden external URL opening with protocol validation.
The openLinkNewTab function (lines 78-82) opens URLs without scheme validation. This allows non-HTTP(S) schemes (javascript:, data:, etc.) to be opened if upstream data is compromised. Add protocol allowlisting before window.open.
🔒 Proposed fix for openLinkNewTab
const openLinkNewTab = (url?: string) => {
if (!url) return;
+ try {
+ const parsed = new URL(url, window.location.origin);
+ if (!["http:", "https:"].includes(parsed.protocol)) return;
+ window.open(parsed.toString(), "_blank", "noopener,noreferrer");
+ } catch {
+ return;
+ }
- window.open(url, "_blank", "noopener,noreferrer");
};The same vulnerability exists at src/app/groups/[id]/page.tsx:357 where window.open(item.url, ...) is called without validation. Apply the same fix there.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@src/components/layout/BookSelectModal.tsx` around lines 78 - 82, Validate and
allowlist URL schemes before calling window.open: in openLinkNewTab, parse the
url with the URL constructor (guarding with try/catch), check that
urlObj.protocol is "http:" or "https:", and only then call window.open(url,
"_blank", "noopener,noreferrer"); apply the same change where
window.open(item.url, ...) is used (parse item.url, ensure protocol is
http/https, and skip opening otherwise) so non-HTTP(S) schemes like javascript:
or data: are rejected.
| getLatestNotice: async (clubId: number) => { | ||
| try { | ||
| const res = await apiClient.get<LatestNoticeResponse>(CLUBS.latestNotice(clubId)); | ||
| return res.result; | ||
| } catch (e: any) { | ||
| const msg = e?.message ?? ""; | ||
| if ( | ||
| msg.includes("공지") && (msg.includes("없") || msg.includes("존재하지")) | ||
| ) { | ||
| return null; | ||
| } | ||
| throw e; | ||
| } | ||
| }, | ||
|
|
||
| getNextMeeting: async (clubId: number) => { | ||
| try { | ||
| const res = await apiClient.get<NextMeetingResponse>(CLUBS.nextMeeting(clubId)); | ||
| return res.result; | ||
| } catch (e: any) { | ||
| const msg = e?.message ?? ""; | ||
| if (msg.includes("다음 정기모임이 존재하지 않습니다")) { | ||
| return null; | ||
| } | ||
| if (msg.includes("정기모임") && (msg.includes("없") || msg.includes("존재하지"))) { | ||
| return null; | ||
| } | ||
| throw e; | ||
| } | ||
| }, |
There was a problem hiding this comment.
🧩 Analysis chain
🏁 Script executed:
cat -n src/services/clubService.ts | head -120 | tail -60Repository: checkmo2025/FE
Length of output: 2146
🏁 Script executed:
rg "apiClient\s*=" -A 2 -B 2 --type ts --type tsx | head -50Repository: checkmo2025/FE
Length of output: 85
🏁 Script executed:
fd -e ts -e tsx | xargs rg "apiClient.*get" -B 1 -A 1 | head -80Repository: checkmo2025/FE
Length of output: 4860
🏁 Script executed:
fd -e ts | xargs rg "apiClient\s*=" -B 5 -A 5Repository: checkmo2025/FE
Length of output: 1420
🏁 Script executed:
fd -e ts | xargs rg "axios|fetch|http" -B 2 -A 2 | head -100Repository: checkmo2025/FE
Length of output: 3830
🏁 Script executed:
fd -e ts | xargs rg "response.*status|error.*status" -B 2 -A 2 | head -80Repository: checkmo2025/FE
Length of output: 1446
🏁 Script executed:
cat -n src/lib/api/client.tsRepository: checkmo2025/FE
Length of output: 4210
🏁 Script executed:
fd -name "ApiError.ts" -o -name "ApiError.js"Repository: checkmo2025/FE
Length of output: 288
🏁 Script executed:
cat -n src/lib/api/ApiError.tsRepository: checkmo2025/FE
Length of output: 394
🏁 Script executed:
rg "ApiError|\.code|\.message" src/services/ -A 2 -B 2 | head -100Repository: checkmo2025/FE
Length of output: 1899
🏁 Script executed:
rg "HTTP404|HTTP" src/ --type ts | head -30Repository: checkmo2025/FE
Length of output: 333
🏁 Script executed:
rg "404|HTTP404|\\.code" src/services/ -B 3 -A 3 | head -100Repository: checkmo2025/FE
Length of output: 40
🏁 Script executed:
rg "catch.*ApiError|instanceof ApiError" src/ --type tsRepository: checkmo2025/FE
Length of output: 142
🏁 Script executed:
rg "getLatestNotice|latestNotice|nextMeeting" src/ --type ts -B 2 -A 2Repository: checkmo2025/FE
Length of output: 4749
🏁 Script executed:
cat -n src/lib/api/ApiError.tsRepository: checkmo2025/FE
Length of output: 394
🏁 Script executed:
rg "HTTP404|HTTP401" src/ --type ts | head -20Repository: checkmo2025/FE
Length of output: 40
🏁 Script executed:
rg "instanceof.*ApiError|e\.code" src/ --type ts -B 2 -A 2 | head -60Repository: checkmo2025/FE
Length of output: 462
🏁 Script executed:
rg "getLatestNotice|getNextMeeting" src/hooks/ -B 2 -A 5Repository: checkmo2025/FE
Length of output: 1029
Use HTTP error codes for null fallbacks, not message text matching.
getLatestNotice and getNextMeeting depend on Korean message substrings, which breaks when the backend changes error messages or adds i18n.
The error object has a code property (e.g., "HTTP404") that should be checked instead:
💡 Suggested fix
-getLatestNotice: async (clubId: number) => {
+getLatestNotice: async (clubId: number) => {
try {
const res = await apiClient.get<LatestNoticeResponse>(CLUBS.latestNotice(clubId));
return res.result;
- } catch (e: any) {
- const msg = e?.message ?? "";
- if (
- msg.includes("공지") && (msg.includes("없") || msg.includes("존재하지"))
- ) {
+ } catch (e) {
+ if ((e as any)?.code === "HTTP404") {
return null;
}
throw e;
}
},🧰 Tools
🪛 ESLint
[error] 73-73: Unexpected any. Specify a different type.
(@typescript-eslint/no-explicit-any)
[error] 88-88: 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/services/clubService.ts` around lines 69 - 98, Replace brittle Korean
message substring checks in getLatestNotice and getNextMeeting with an explicit
HTTP error-code check on the caught error object (e.g., if (e?.code ===
"HTTP404") return null); locate the catch blocks in the getLatestNotice and
getNextMeeting functions that currently inspect e.message, change them to
inspect e.code (or e?.code) for the 404 indicator and return null in that case,
and otherwise rethrow the original error; keep existing calls to apiClient.get
and the returned types LatestNoticeResponse and NextMeetingResponse intact.
| profileImageUrl: string | null; | ||
| region: string | null; | ||
| description: string; | ||
| profileImageUrl: string; |
There was a problem hiding this comment.
profileImageUrl nullability looks inconsistent with nearby club DTOs.
In related club types, this field is nullable. Keeping it as non-null here can lead to unsafe assumptions and runtime UI issues when no image exists. Please align the contract (or normalize at service boundary).
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@src/types/groups/grouphome.ts` at line 48, The profileImageUrl field in the
GroupHome DTO is currently declared as a non-null string but must be nullable to
match related club DTOs; change the profileImageUrl type to allow null (e.g.,
string | null) in the GroupHome/interface declaration and then update any
constructors, mappers, serializers, or consumers that build or read this DTO
(look for functions that create GroupHome objects or read profileImageUrl) to
handle the null case (or normalize to an empty string at the service boundary if
you prefer) and add/update tests to cover missing images.
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 57 out of 60 changed files in this pull request and generated 9 comments.
Comments suppressed due to low confidence (1)
src/services/clubService.ts:49
searchClubs에서cleaned: any+ eslint-disable로 타입을 우회하고 있습니다.cursorId/keyword/inputFilter만 선택적으로 제거하려면Record<string, unknown>로 잡거나,ClubSearchParams를 기반으로 한 좁은 타입(예:Partial<ClubSearchParams>)을 사용해서any없이 처리해 주세요.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| </div> | ||
|
|
||
| <div className="mx-auto w-full max-w-[1440px] px-4 t:px-6 py-3 t:pt-3 t:pb-8"> | ||
| <div className="mx-auto w-full max-w-[1440px] px-auto t:px-6 py-3 t:pt-3 t:pb-8"> |
There was a problem hiding this comment.
className에 px-auto가 들어가 있는데 Tailwind padding 유틸리티에는 px-auto가 없습니다(무시되거나 lint/IDE 경고가 날 수 있음). 의도한 좌우 패딩 값(예: px-4/px-6/px-0)으로 수정하거나 해당 토큰을 제거해 주세요.
| <div className="mx-auto w-full max-w-[1440px] px-auto t:px-6 py-3 t:pt-3 t:pb-8"> | |
| <div className="mx-auto w-full max-w-[1440px] t:px-6 py-3 t:pt-3 t:pb-8"> |
| // 공통 응답 포맷 | ||
| export interface ApiResponse<T> { | ||
| isSuccess: boolean; | ||
| code: string; | ||
| message: string; | ||
| result: T; | ||
| } |
There was a problem hiding this comment.
이 파일에서 ApiResponse를 로컬로 다시 정의하고 있는데, 이미 src/lib/api/types.ts에 동일 타입이 존재합니다. 동일 개념의 타입이 여러 곳에 생기면 점진적으로 필드가 어긋날 수 있으니 ApiResponse는 공용 타입(@/lib/api/types)을 import 해서 사용하도록 정리해 주세요.
| }} | ||
| items={MOCK_REVIEWS} | ||
| onClickMore={(id) => console.log("more:", id)} | ||
| items={reviewItems as any} |
There was a problem hiding this comment.
topicItems/reviewItems가 이미 DebateSection/ReviewSection이 기대하는 shape로 매핑되어 있는데 as any로 타입을 무력화하고 있습니다. 추후 필드 변경 시 컴파일 단계에서 못 잡히니, topicItems/reviewItems의 반환 타입을 명시하거나 해당 컴포넌트 타입에 맞게 제네릭/타입을 맞춰 as any를 제거해 주세요.
| items={reviewItems as any} | |
| items={reviewItems} |
| if (!data) return; | ||
| const existingTeamNumbers = | ||
| data.existingTeams?.map((t: any) => Number(t.teamNumber)).filter(Number.isFinite) ?? []; | ||
|
|
||
| const normalized = normalizeTeams(existingTeamNumbers); | ||
| setTeams(normalized); | ||
|
|
||
| const mappedMembers: TeamMember[] = | ||
| data.clubMembers?.map((cm: any) => ({ | ||
| clubMemberId: cm.clubMemberId, | ||
| memberInfo: { | ||
| nickname: cm.memberInfo?.nickname ?? "", | ||
| profileImageUrl: cm.memberInfo?.profileImageUrl ?? "", | ||
| }, | ||
| teamNumber: cm.teamKey?.teamNumber ?? null, | ||
| })) ?? []; |
There was a problem hiding this comment.
useMeetingMembersQuery의 data는 이미 응답 타입을 알고 있는데 existingTeams/clubMembers를 any로 캐스팅해서 매핑하고 있습니다. 실제 타입(GetMeetingMembersResponseResult)을 그대로 사용하면 필드명/nullable 이슈를 컴파일 타임에 잡을 수 있으니 any를 제거하고 타입에 맞게 매핑을 정리해 주세요(예: data.existingTeams.map(t => t.teamNumber) 등).
| getLatestNotice: async (clubId: number) => { | ||
| try { | ||
| const res = await apiClient.get<LatestNoticeResponse>(CLUBS.latestNotice(clubId)); | ||
| return res.result; | ||
| } catch (e: any) { | ||
| const msg = e?.message ?? ""; | ||
| if ( | ||
| msg.includes("공지") && (msg.includes("없") || msg.includes("존재하지")) | ||
| ) { | ||
| return null; | ||
| } | ||
| throw e; | ||
| } | ||
| }, | ||
|
|
||
| getNextMeeting: async (clubId: number) => { | ||
| try { | ||
| const res = await apiClient.get<NextMeetingResponse>(CLUBS.nextMeeting(clubId)); | ||
| return res.result; | ||
| } catch (e: any) { | ||
| const msg = e?.message ?? ""; | ||
| if (msg.includes("다음 정기모임이 존재하지 않습니다")) { | ||
| return null; | ||
| } | ||
| if (msg.includes("정기모임") && (msg.includes("없") || msg.includes("존재하지"))) { | ||
| return null; | ||
| } | ||
| throw e; | ||
| } | ||
| }, |
There was a problem hiding this comment.
getLatestNotice/getNextMeeting에서 에러를 문자열 메시지 포함 여부로 분기하고 any로 캐치하고 있습니다. apiClient는 ApiError(message, code, data)를 던지므로, 메시지 파싱 대신 e instanceof ApiError + e.code(또는 e.data.code) 기반으로 ‘없음’ 케이스만 null 처리하고 나머지는 그대로 throw 하는 형태로 바꿔 주세요. 이렇게 해야 메시지 변경/번역에 의해 정상 에러가 조용히 삼켜지는 것을 막을 수 있습니다.
| export interface ClubHomeResponseResult { | ||
| clubId: number; | ||
| name: string; | ||
| profileImageUrl: string | null; | ||
| region: string | null; | ||
| description: string; | ||
| profileImageUrl: string; | ||
| region: string; | ||
| category: ClubCategory[]; | ||
| participantTypes: ParticipantType[]; | ||
| links: ClubLinkItem[]; | ||
| open: boolean; | ||
| } |
There was a problem hiding this comment.
ClubHomeResponseResult.profileImageUrl/region이 string으로 고정되어 있는데, 기존 코드에서는 null 허용이었고 다른 DTO(ClubDTO 등)에서도 profileImageUrl: string | null로 정의되어 있습니다. 실제 API가 null을 내려주면 타입이 틀어지므로 string | null(필요 시 region도)로 맞춰 주세요.
| import { API_BASE_URL } from "@/lib/api/endpoints"; | ||
|
|
||
| export const CLUBS_BOOKSHELF_ENDPOINTS = { | ||
|
|
||
| simpleBookshelves: (clubId: number | string) => `${API_BASE_URL}/clubs/${clubId}/bookshelves`, | ||
|
|
There was a problem hiding this comment.
API_BASE_URL를 @/lib/api/endpoints(re-export index)에서 가져오면 index.ts가 다시 ./ClubsBookshelf를 re-export하면서 순환 의존이 생길 수 있습니다(이미 Clubs.ts도 같은 패턴). 런타임/번들러에 따라 값이 undefined로 초기화되는 케이스를 피하려면 API_BASE_URL는 @/lib/api/endpoints/base(또는 상대경로 ./base)에서 직접 import 해 주세요.
| } | ||
|
|
||
| import type { InfiniteData } from "@tanstack/react-query"; | ||
|
|
||
| export function useBookshelfTopicsInfiniteQuery(clubId: number, meetingId: number) { |
There was a problem hiding this comment.
모듈 중간(함수 선언 이후)에 import type가 들어가 있습니다. 문법적으로는 가능하지만 가독성이 떨어지고 일부 린트 규칙에서 에러가 될 수 있으니 상단 import 블록으로 이동해 주세요.
| {!isEditing ? ( | ||
| <p className="text-Gray-6 body_2_3 d:body_1_2 break-words whitespace-pre-wrap justifys-center"> | ||
| {item.content} | ||
| </p> |
There was a problem hiding this comment.
Tailwind 클래스 justifys-center는 오타로 보이며 실제로 적용되지 않습니다. 의도한 정렬 클래스(예: justify-center)로 수정하거나 불필요하면 제거해 주세요.
Removed duplicate import of InfiniteData from @tanstack/react-query.
📌 개요 (Summary)
commit [feat : 모임홈 API 연결]~[fix : build 오류 수정]
[모임 홈화면]
나의 상태 조회 GET /api/clubs/{clubId}/me -> 관리자 유무
모임 홈 화면 GET /api/clubs/{clubId}/home
가장 최근 공지사항 1개 조회 GET /api/clubs/{clubId}/notices/latest
이번 모임 바로가기 GET /api/clubs/{clubId}/meetings/next
commit [chore : 책장 ENDPOINT,type 등]~[fix : build error 수정]
[모임 관리]
page1 : 모임 가입 신청 관리
독서 모임 회원 관리(받아오기) : GET /api/clubs/{clubId}/members
독서 모임 회원 등급 수정(수정하기) : PATCH /api/clubs/{clubId}/members/{clubMemberId}
page2 : 모임 회원 관리
독서 모임 회원 관리(받아오기) : GET /api/clubs/{clubId}/members
독서 모임 회원 등급 수정(수정하기) : PATCH /api/clubs/{clubId}/members/{clubMemberId}
UI짜기
page3 : 모임 수정 (생성 페이지 사용 UI만들기)
[운영진] 독서 모임 상세 조회 GET /api/clubs/{clubId}
[운영진] 독서 모임 정보 수정 PUT /api/clubs/{clubId}
[책장 홈]
책장 간편 조회 GET /api/clubs/{clubId}/bookshelves?cursorId=[마지막으로 조회한 책장 id]
[책장 생성]
책 검색하기 GET /api/books/search?keyword=[검색할 단어]&page=[검색할 페이지] -> 책 등록
책장 생성 POST /api/clubs/{clubId}/bookshelves
[책장 수정] (책장 상세에서 이동, UI 내가 제작해야함)
책장 수정 조회 GET /api/clubs/{clubId}/bookshelves/{meetingId}/edit
책장 수정 PATCH /api/clubs/{clubId}/bookshelves/{meetingId} -> 수정되어야하는 요소 변경
commit [chore : image 추가] ~ [feat : 책장 상세페이지 관리자일경우 뜨는 버튼]
나의 상태 조회 GET /api/clubs/{clubId}/me
책장 삭제 DELETE /api/clubs/{clubId}/bookshelves/{meetingId}
[책장 상세 - 발제]
책장 상세 조회 GET /api/clubs/{clubId}/bookshelves/{meetingId}
책장에 대한 발제 조회 GET /api/clubs/{clubId}/bookshelves/{meetingId}/topics?cursorId=[마지막으로 조회한 발제 id]
발제 등록 POST /api/clubs/{clubId}/bookshelves/{meetingId}/topics
발제 수정 PATCH /api/clubs/{clubId}/bookshelves/{meetingId}/topics/{topicId}
발제 삭제 DELETE /api/clubs/{clubId}/bookshelves/{meetingId}/topics/{topicId}
[책장 상세 - 한줄평]
책장 상세 조회 GET /api/clubs/{clubId}/bookshelves/{meetingId}
책장에 대한 한줄평 조회 /api/clubs/{clubId}/bookshelves/{meetingId}/reviews?cursorId=[마지막으로 조회한 한줄평 id]
한줄평 생성 POST/api/clubs/{clubId}/bookshelves/{meetingId}/reviews
한줄평 수정 PATCH /api/clubs/{clubId}/bookshelves/{meetingId}/reviews/{reviewId}
한줄평 삭제 DELETE /api/clubs/{clubId}/bookshelves/{meetingId}/reviews/{reviewId}
[책장 상세 - 정기모임]
책장 상세 조회 GET /api/clubs/{clubId}/bookshelves/{meetingId}
정기모임 조회 GET /api/clubs/{clubId}/meetings/{meetingId}
[책장 상세 - 정기모임(조 관리 페이지)]
조 관리 - 독서모임 회원 전체 조회 GET /api/clubs/{clubId}/meetings/{meetingId}/members
정기모임 조 관리 PUT /api/clubs/{clubId}/meetings/{meetingId}/teams
🛠️ 변경 사항 (Changes)
📸 스크린샷 (Screenshots)
이거 웬만하면 다 스크릿샷찍고 하고 싶은데 너무 많아서 직접 UI 보셔야합니다.
✅ 체크리스트 (Checklist)
pnpm build)Summary by CodeRabbit
Release Notes
New Features
Refactor