Repository navigation
Conversation
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- 정적 페이지(landing, home, 소식, 책이야기, 독서모임, 도서검색, 회원가입 등) metadata 추가 - 동적 페이지(소식/책이야기/모임/도서/프로필 상세) generateMetadata로 API 기반 동적 metadata 적용 - setting/*, profile/mypage에 noindex 적용 - OG URL을 checkmo.co.kr로 수정 - "use client" 페이지를 서버 컴포넌트로 전환 후 *Client.tsx로 분리 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
'모임명 - 독서 모임 | 책모' → '모임명 | 책모' Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- stories/new: "책이야기 - 작성 | 책모" - groups/create: "모임 생성 | 책모" - groups/[id]/notice: "모임명 - 공지사항 | 책모" - groups/[id]/bookcase: "모임명 - 책장 | 책모" Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- bookcase/[bookId]: "모임명 - 책장 | 책모" - bookcase/[bookId]/meeting: "모임명 - 책장 | 책모" - notice/[noticeId]: "모임명 - 공지사항 | 책모" Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- 마이페이지: "마이페이지 | 책모" - 팔로우: "팔로우 | 책모" - 책이야기 수정: "제목 - 수정 | 책모" (동적) - 설정 17개 페이지: 설정/계정상태/차단관리/이메일변경/로그아웃/새소식/알림설정/비밀번호변경/프로필수정/신고내역/고객지원/이용약관/버전정보 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- 팔로우 페이지: "팔로우" → "구독 | 책모" - 설정 모든 하위 페이지: 각자 타이틀 → "설정 | 책모" 통일 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…any 제거 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…adata 유지 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
fix: 랜딩페이지 버튼 hover 커서 및 시각 효과 추가
chore: 페이지별 타이틀/description 설정
chore: OG 이미지 및 Twitter 카드 메타데이터 추가
chore: robots.txt 추가 - 크롤러 접근 제어 설정
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
There was a problem hiding this comment.
Code Review
This pull request implements a large-scale refactoring of the application's routing and component structure, moving client-side logic into dedicated *Client.tsx components. This change facilitates the integration of Next.js App Router features, specifically dynamic metadata generation for SEO and the use of Suspense for improved loading performance. The update also includes the addition of a robots.ts file and styling refinements for landing page components. Review feedback highlights opportunities to further optimize performance by using URL parameters as the primary source of truth instead of local state, correcting invalid Tailwind CSS utility classes, and improving code maintainability through better function scoping and removal of redundant event listeners. A security-related suggestion was also made to strengthen password validation logic.
| const [activeTab, setActiveTab] = useState<TabKey>("meeting"); | ||
|
|
||
| const { user } = useAuthStore(); | ||
|
|
||
| const myName = user?.nickname ?? "My_Name"; | ||
| const myProfileImageUrl = user?.profileImageUrl ?? "/profile4.svg"; | ||
|
|
||
| const [isDebateWriting, setIsDebateWriting] = useState(false); | ||
| const [isReviewWriting, setIsReviewWriting] = useState(false); | ||
|
|
||
| const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false); | ||
| const [reportTarget, setReportTarget] = useState<{ | ||
| reportedMemberNickname: string; | ||
| } | null>(null); | ||
|
|
||
| const { data: meData } = useClubMeQuery(clubId); | ||
| const isStaff = !!meData?.staff; | ||
|
|
||
| const { | ||
| data: bookshelfDetail, | ||
| isLoading: isBookshelfLoading, | ||
| isError: isBookshelfError, | ||
| } = useBookshelfDetailQuery(clubId, meetingId); | ||
|
|
||
| const topicsQuery = useBookshelfTopicsInfiniteQuery(clubId, meetingId); | ||
| const reviewsQuery = useBookshelfReviewsInfiniteQuery(clubId, meetingId); | ||
|
|
||
| const { mutate: createTopic } = useCreateTopicMutation(); | ||
| const { mutate: updateTopic } = useUpdateTopicMutation(); | ||
| const { mutate: deleteTopic } = useDeleteTopicMutation(); | ||
|
|
||
| const { mutate: createReview } = useCreateReviewMutation(); | ||
| const { mutate: updateReview } = useUpdateReviewMutation(); | ||
| const { mutate: deleteReview } = useDeleteReviewMutation(); | ||
|
|
||
| const { mutateAsync: deleteBookshelf, isPending: isDeletingBookshelf } = | ||
| useDeleteBookshelfMutation(); | ||
|
|
||
| const { mutateAsync: reportMember } = useReportMemberMutation(); | ||
|
|
||
| useEffect(() => { | ||
| const tab = searchParams.get("tab"); | ||
| if (isTabKey(tab) && tab !== activeTab) setActiveTab(tab); | ||
|
|
||
| if (!tab) { | ||
| const next = new URLSearchParams(searchParams.toString()); | ||
| next.set("tab", "meeting"); | ||
| router.replace(`${pathname}?${next.toString()}`, { scroll: false }); | ||
| } | ||
| }, [searchParams]); |
| @@ -0,0 +1,665 @@ | |||
| /* eslint-disable react-hooks/set-state-in-effect */ | |||
| </div> | ||
|
|
||
| {!isEmpty && ( | ||
| <div className="fixed bottom-20 left-0 right-0 t:bottom-4 d:bottom-4 d:left-50 py-4 flex items-center justify-center gap-0 z-50"> |
There was a problem hiding this comment.
d:left-50은 표준 Tailwind CSS 클래스가 아닙니다. 중앙 정렬을 의도했다면 d:left-1/2 d:-translate-x-1/2를 사용하거나, 특정 위치를 의도했다면 d:left-[50px]와 같이 정확한 단위를 명시해야 합니다.
| <div className="fixed bottom-20 left-0 right-0 t:bottom-4 d:bottom-4 d:left-50 py-4 flex items-center justify-center gap-0 z-50"> | |
| <div className="fixed bottom-20 left-0 right-0 t:bottom-4 d:bottom-4 d:left-1/2 d:-translate-x-1/2 py-4 flex items-center justify-center gap-0 z-50"> |
| const formatDate = (iso: string) => { | ||
| const d = new Date(iso); | ||
| const yyyy = d.getFullYear(); | ||
| const mm = String(d.getMonth() + 1).padStart(2, "0"); | ||
| const dd = String(d.getDate()).padStart(2, "0"); | ||
| return `${yyyy}.${mm}.${dd}`; | ||
| }; |
| return; | ||
| } | ||
|
|
||
| const passwordRegex = /^(?=.*[a-zA-Z])(?=.*[!@#$%^&*]).{6,12}$/; |
There was a problem hiding this comment.
현재 비밀번호 정규식은 영문자와 특수문자만 체크하며 숫자를 포함하도록 강제하지 않습니다. 보안 수준을 높이기 위해 숫자 포함 여부도 체크하는 정규식으로 개선하는 것을 권장합니다. (예: /^(?=.*[a-zA-Z])(?=.*[!@#$%^&*])(?=.*[0-9]).{8,16}$/)
| const passwordRegex = /^(?=.*[a-zA-Z])(?=.*[!@#$%^&*]).{6,12}$/; | |
| const passwordRegex = /^(?=.*[a-zA-Z])(?=.*[!@#$%^&*])(?=.*[0-9]).{6,12}$/; |
| setClubDescription(e.target.value); | ||
| autoResize(e.currentTarget); | ||
| }} | ||
| onInput={(e) => autoResize(e.currentTarget)} |
📝 WalkthroughWalkthroughLarge refactor: interactive logic moved into new client components while Next.js route files become server wrappers with generateMetadata. Adds root layout social metadata, robots route, and minor landing UI style updates. ChangesApp Router client/server split across features
Global SEO: layout metadata and robots
Landing UI polish
Sequence Diagram(s)sequenceDiagram
participant ServerRoute
participant API
participant ClientComponent
ServerRoute->>API: fetch data for generateMetadata
API-->>ServerRoute: JSON (title/og data)
ServerRoute-->>ClientComponent: render interactive UI
Estimated code review effort🎯 5 (Critical) | ⏱️ ~120 minutes Possibly related PRs
Suggested labels
Poem
✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
|
There was a problem hiding this comment.
Actionable comments posted: 17
Note
Due to the large number of review comments, Critical, Major severity comments were prioritized as inline comments.
🟡 Minor comments (11)
src/app/(main)/news/[id]/NewsDetailClient.tsx-24-33 (1)
24-33:⚠️ Potential issue | 🟡 Minor | ⚡ Quick winUse the rendered image list length for carousel indexing.
Line 28 rotates with
news.imageUrls.length, but rendering uses filteredimageUrls(Line 71). When invalid URLs are removed, the index can exceed rendered bounds and show a blank frame.🛠️ Proposed fix
- useEffect(() => { - if (!news || !news.imageUrls || news.imageUrls.length <= 1) return; - - const timer = setInterval(() => { - setCurrentImageIndex((prev) => (prev + 1) % news.imageUrls.length); - }, 5000); - - return () => clearInterval(timer); - }, [news?.imageUrls?.length]); - const recommendedBooks = useMemo(() => { @@ const filteredUrls = rawImageUrls.filter(isValidSrc); const imageUrls = filteredUrls.length > 0 ? filteredUrls : ["/news_sample4.svg"]; + + useEffect(() => { + setCurrentImageIndex(0); + }, [imageUrls.length]); + + useEffect(() => { + if (imageUrls.length <= 1) return; + const timer = setInterval(() => { + setCurrentImageIndex((prev) => (prev + 1) % imageUrls.length); + }, 5000); + return () => clearInterval(timer); + }, [imageUrls.length]);Also applies to: 65-71
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@src/app/`(main)/news/[id]/NewsDetailClient.tsx around lines 24 - 33, The carousel uses news.imageUrls.length for rotation but the UI renders a filtered list, so compute a single renderedImages array (e.g., validImageUrls or filteredImageUrls) from news.imageUrls (same filter used around the render block) and use that array everywhere: in the useEffect guard, in the setInterval modulo (setCurrentImageIndex(prev => (prev + 1) % renderedImages.length)), and in the render logic that maps images (instead of directly using news.imageUrls); also update the effect dependency to [renderedImages.length] and ensure currentImageIndex is clamped/reset if renderedImages becomes shorter than the current index.src/app/(public)/find-password/FindPasswordPageClient.tsx-21-27 (1)
21-27:⚠️ Potential issue | 🟡 Minor | ⚡ Quick winValidate and normalize email before mutation call.
At Line 22, whitespace-only or invalid email format still proceeds to API. Add
trim()+ basic email format validation beforesendTempPassword(...).Proposed fix
const handleSendTempPassword = () => { - if (!email) { + const normalizedEmail = email.trim(); + const emailPattern = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + + if (!normalizedEmail) { toast.error("이메일을 입력해주세요."); return; } + if (!emailPattern.test(normalizedEmail)) { + toast.error("올바른 이메일 형식을 입력해주세요."); + return; + } - sendTempPassword(email, { + sendTempPassword(normalizedEmail, { onSuccess: () => { router.push("/"); } }); };🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@src/app/`(public)/find-password/FindPasswordPageClient.tsx around lines 21 - 27, The handler handleSendTempPassword currently allows whitespace-only or malformed emails to reach sendTempPassword; update it to trim the email (const normalizedEmail = email?.trim()) and run a basic email format check (e.g., simple regex) before calling sendTempPassword; if normalizedEmail is empty or fails validation, call toast.error("이메일을 입력해주세요." or a format-specific message) and return, otherwise call sendTempPassword(normalizedEmail, {...}) so the mutation always receives a normalized, validated address.src/app/(main)/search/SearchPageClient.tsx-33-39 (1)
33-39:⚠️ Potential issue | 🟡 Minor | ⚡ Quick winUse
isLoadingto avoid flashing a false0개state.On a fresh search, this renders
총 0개until the first page resolves becauseisLoadingis ignored andsearchResultsstarts empty. That briefly shows incorrect feedback on every new query.Proposed fix
- <p className="max-w-[1040px] text-Gray-4 body_1_3 t:subhead_4_1 ml-2 t:ml-0 mb-4"> - 총 <span className="text-primary-3">{totalResults}개</span>의 검색결과가 있습니다. - </p> + {!isLoading && ( + <p className="max-w-[1040px] text-Gray-4 body_1_3 t:subhead_4_1 ml-2 t:ml-0 mb-4"> + 총 <span className="text-primary-3">{totalResults}개</span>의 검색결과가 있습니다. + </p> + )}Also applies to: 100-104
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@src/app/`(main)/search/SearchPageClient.tsx around lines 33 - 39, The total-count UI is showing "총 0개" during the initial fetch because useInfiniteBookSearchQuery's isLoading is ignored and searchResults is computed empty; update the render logic in SearchPageClient (the area using useInfiniteBookSearchQuery, searchResults, and total count) to respect isLoading: when isLoading is true show a loading state (or suppress the total count) instead of computing/printing total from an empty searchResults, and only compute/display the aggregated total from data.pages (or from searchResults) once isLoading is false (or data exists); ensure similar fixes apply to the other occurrences flagged (lines ~100-104) where total or empty-state is derived without checking isLoading.src/app/(main)/search/SearchPageClient.tsx-21-25 (1)
21-25:⚠️ Potential issue | 🟡 Minor | ⚡ Quick winAllow empty submissions to clear the active search.
After the user clears the input, pressing Enter does nothing, so the old
qparam stays in the URL and the previous results remain on screen. Route to/searchwhen the trimmed query is empty.Proposed fix
const handleSearch = () => { - if (searchValue.trim()) { - router.push(`/search?q=${encodeURIComponent(searchValue.trim())}`); - } + const nextQuery = searchValue.trim(); + router.push(nextQuery ? `/search?q=${encodeURIComponent(nextQuery)}` : "/search"); };🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@src/app/`(main)/search/SearchPageClient.tsx around lines 21 - 25, handleSearch currently only routes when searchValue.trim() is non-empty so clearing the input and pressing Enter leaves the old q param; update handleSearch to call router.push('/search') when searchValue.trim() is empty (and keep router.push(`/search?q=${encodeURIComponent(searchValue.trim())}`) when non-empty) so the URL and displayed results are cleared; modify the logic in the handleSearch function that uses searchValue and router.push accordingly.src/app/(main)/groups/[id]/bookcase/BookcasePageClient.tsx-76-82 (1)
76-82:⚠️ Potential issue | 🟡 Minor | ⚡ Quick winUse the same positive/finite guard you already use for the query.
Right now only
NaNis rejected here. Values like0,-1, orInfinityskip fetching but still render the empty-state message instead of an invalid-route message.Suggested 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 current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@src/app/`(main)/groups/[id]/bookcase/BookcasePageClient.tsx around lines 76 - 82, The current guard in BookcasePageClient.tsx only checks Number.isNaN(groupId) so values like 0, negative numbers or Infinity still pass and render the wrong empty-state; change the validation to the same positive/finite check used by the query: replace the NaN check with a guard like if (!(Number.isFinite(groupId) && groupId > 0)) and return the "잘못된 모임 ID" invalid-route UI when that condition is true (update the check around groupId in the BookcasePageClient component).src/app/(main)/groups/[id]/bookcase/[bookId]/BookDetailPageClient.tsx-104-112 (1)
104-112:⚠️ Potential issue | 🟡 Minor | ⚡ Quick winNormalize invalid
tabquery values.When
tabis present but unsupported, the UI falls back to"meeting"while the URL keeps the bad value. That leaves copied/reloaded links in a broken state. Treat unknown values the same as a missing param and replace them withtab=meeting.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@src/app/`(main)/groups/[id]/bookcase/[bookId]/BookDetailPageClient.tsx around lines 104 - 112, The useEffect handling searchParams currently ignores invalid tab values in the URL; when searchParams.get("tab") returns a value that is not isTabKey(tab) you should treat it like a missing param: setActiveTab("meeting"), create a new URLSearchParams from searchParams, set("tab","meeting"), and call router.replace(`${pathname}?${next.toString()}`, { scroll: false }) so the URL is normalized; keep the existing branch that only sets activeTab for valid tabs but add this branch for the invalid-tab case (use the same symbols: useEffect, searchParams, isTabKey, activeTab, router.replace, pathname).src/app/(main)/groups/[id]/notice/NoticePageClient.tsx-43-49 (1)
43-49:⚠️ Potential issue | 🟡 Minor | ⚡ Quick winAvoid local-time parsing for notice dates.
new Date(iso)shifts the calendar day for UTC timestamps near midnight. The detail page formats dates by slicing the server string, so list/detail can disagree by one day.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@src/app/`(main)/groups/[id]/notice/NoticePageClient.tsx around lines 43 - 49, The formatDate function currently parses ISO timestamps with new Date(iso) which converts to local time and can shift the day; update formatDate (in NoticePageClient.tsx) to format the server date in UTC (e.g., use Date methods that read UTC like getUTCFullYear/getUTCMonth/getUTCDate) or simply derive the yyyy-mm-dd from the ISO string (iso.slice(0,10)) and replace '-' with '.' so list and detail pages always use the server date without local-time shifts.src/app/(main)/profile/mypage/MypageClient.tsx-15-22 (1)
15-22:⚠️ Potential issue | 🟡 Minor | ⚡ Quick winKeep
tabandactiveTabsynchronized.This page initializes from
?tab=..., but later tab changes never update the URL. After switching tabs, refresh/bookmark/share will restore the wrong section. Either mirroractiveTabback into the query string or derive the rendered tab directly fromsearchParams.Also applies to: 36-40
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@src/app/`(main)/profile/mypage/MypageClient.tsx around lines 15 - 22, The tab state is initialized from useSearchParams but later changes to activeTab (const [activeTab, setActiveTab]) don't update the URL, so sharing/refresh loses the current tab; fix by synchronizing activeTab and the query param: either derive the rendered tab directly from useSearchParams (remove local state) or keep activeTab but add an effect that updates searchParams when activeTab changes (use setSearchParams or router.replace) and also listen for searchParams changes to update activeTab (useEffect reading useSearchParams). Update the logic around initialTab, activeTab, setActiveTab and any handlers that change tabs (also apply the same pattern referenced around lines 36-40) so the URL and component state remain consistent.src/app/(main)/profile/[nickname]/follows/NicknameFollowsClient.tsx-20-21 (1)
20-21:⚠️ Potential issue | 🟡 Minor | ⚡ Quick winClamp
tabto the supported values.The cast hides invalid query strings from TypeScript, but
?tab=foostill reaches runtime and leavesactiveTabunsupported, which disables both list queries. Parse the param explicitly and fall back to"follower"instead.Suggested fix
- const initialTab = (searchParams?.get("tab") as "follower" | "following") || "follower"; + const tabParam = searchParams?.get("tab"); + const initialTab: "follower" | "following" = + tabParam === "following" ? "following" : "follower";🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@src/app/`(main)/profile/[nickname]/follows/NicknameFollowsClient.tsx around lines 20 - 21, The code currently casts the query param directly which allows invalid values to reach runtime; update the logic that computes initialTab (and thus activeTab) to explicitly read searchParams.get("tab"), check if it's exactly "follower" or "following", and only then use it—otherwise default to "follower"; change the initializer around initialTab/activeTab (the variables initialTab, activeTab, setActiveTab, and the useState call in NicknameFollowsClient) to perform this explicit clamp so only the two supported values are used at runtime.src/app/(main)/profile/mypage/follows/MypageFollowsClient.tsx-18-19 (1)
18-19:⚠️ Potential issue | 🟡 Minor | ⚡ Quick winValidate the incoming
tabquery value.The direct cast accepts any string at runtime, so
?tab=foocan leaveactiveTaboutside the supported union and break which list/query branch runs. Normalize it to"follower"or"following"before seeding state.Suggested fix
- const initialTab = (searchParams?.get("tab") as "follower" | "following") || "follower"; + const tabParam = searchParams?.get("tab"); + const initialTab: "follower" | "following" = + tabParam === "following" ? "following" : "follower";🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@src/app/`(main)/profile/mypage/follows/MypageFollowsClient.tsx around lines 18 - 19, The code seeds activeTab by directly casting searchParams.get("tab") to the union which lets invalid strings slip through; update the initialization to validate/normalize the incoming value from searchParams.get("tab") so only "follower" or "following" are accepted (otherwise default to "follower"). Do this by checking the raw string (from searchParams.get("tab")) against the two allowed values (or by adding a small helper like normalizeTab) and use the validated result when calling useState for activeTab (and likewise use the same validation whenever parsing the tab query elsewhere).src/app/(main)/books/[id]/page.tsx-19-23 (1)
19-23:⚠️ Potential issue | 🟡 Minor | ⚡ Quick winPreserve shared Open Graph fields when overriding here.
Next.js App Router metadata merging is shallow: when
src/app/(main)/books/[id]/page.tsxdefines anopenGraphobject ingenerateMetadata, it replaces the parent/rootopenGraphobject for this route (not deep/recursive merged). This will drop shared fields likesiteName,locale,type,url, etc. unless you explicitly merge them. Construct the returnedopenGraphby spreadingparent.openGraph(e.g.,images/overrides only) insidegenerateMetadata.
https://nextjs.org/docs/app/api-reference/functions/generate-metadata🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@src/app/`(main)/books/[id]/page.tsx around lines 19 - 23, The route-level generateMetadata is replacing the parent openGraph object and dropping shared fields; update generateMetadata in src/app/(main)/books/[id]/page.tsx to merge the parent metadata by spreading parent.openGraph into the returned openGraph and then override only the book-specific fields (e.g., images, title, description) so shared keys like siteName, locale, type, and url are preserved.
🧹 Nitpick comments (4)
src/app/robots.ts (2)
24-24: 💤 Low valueHardcoded sitemap URL matches production domain.
The sitemap URL is set to the production domain. This is consistent with the
metadataBasein the root layout, but note that it won't adapt to dev/staging environments if needed.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@src/app/robots.ts` at line 24, The robots configuration hardcodes sitemap: "https://www.checkmo.co.kr/sitemap.xml", which ties it to production; update the sitemap value to derive from the app's runtime base URL (e.g., reuse metadataBase from the root layout or construct from an environment/runtime variable) so it adapts for dev/staging; locate the sitemap property in src/app/robots.ts and replace the literal URL with a dynamic value built from the app's metadataBase or process/env variable (ensuring a trailing / and appending "sitemap.xml") to produce the correct full URL at runtime.
9-21: Clarify wildcard handling fordisallowentries inMetadataRoute.Robots
- Next.js
MetadataRoute.Robotsoutputs eachdisallowstring verbatim intorobots.txtas aDisallow:line (no conversion/sanitization of/groups/*/adminor/stories/*/edit).- Robots.txt wildcard (
*) semantics are not standardized across crawlers, so these patterns may not be interpreted as intended; validate against the specific search engines/crawlers you care about.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@src/app/robots.ts` around lines 9 - 21, The disallow array in the MetadataRoute.Robots configuration (the disallow property in src/app/robots.ts) contains literal wildcard patterns like "/groups/*/admin" and "/stories/*/edit" which Next.js will emit verbatim into robots.txt and may not be honored by crawlers; update the disallow entries to only use concrete path prefixes or validated patterns supported by your target crawlers (e.g., replace "/groups/*/admin" with "/groups/" or explicit paths, or generate multiple concrete Disallow entries), or add a comment and tests explaining which crawlers will interpret "*" if you intend to keep them; locate and modify the disallow array in the MetadataRoute.Robots definition to apply the chosen approach.src/app/landing/LandingPageClient.tsx (1)
16-18: 💤 Low valueConsider if overwriting the timestamp on every visit is intentional.
The
useEffectwrites a new timestamp tolocalStorageon every mount. If the intent is to track only the first visit, add a guard:if (!localStorage.getItem(LANDING_KEY)) { localStorage.setItem(LANDING_KEY, String(Date.now())); }If tracking every visit is intentional, the current implementation is correct.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@src/app/landing/LandingPageClient.tsx` around lines 16 - 18, The current useEffect in LandingPageClient.tsx unconditionally calls localStorage.setItem(LANDING_KEY, String(Date.now())) on every mount; decide whether you want first-visit-only behavior and, if so, change the effect to check localStorage.getItem(LANDING_KEY) before setting it (i.e., only write the timestamp if the key is missing); otherwise leave the useEffect as-is if overwriting on every visit is intentional.src/app/layout.tsx (1)
25-25: 💤 Low valueConsider using an environment variable for metadataBase.
The
metadataBaseURL is hardcoded to the production domain. For dev/staging environments, you might want to useprocess.env.NEXT_PUBLIC_BASE_URLto support different deployment contexts.🌐 Optional: make metadataBase environment-aware
- metadataBase: new URL("https://www.checkmo.co.kr"), + metadataBase: new URL(process.env.NEXT_PUBLIC_BASE_URL || "https://www.checkmo.co.kr"),🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@src/app/layout.tsx` at line 25, The metadataBase value in src/app/layout.tsx is hardcoded to "https://www.checkmo.co.kr"; update metadataBase to read from an environment variable (e.g. process.env.NEXT_PUBLIC_BASE_URL) with a safe fallback to the existing production URL so different deployments (dev/staging/prod) can provide their own base URL; locate the metadataBase assignment in layout.tsx and replace the literal new URL(...) with logic that constructs the URL from the env var (or falls back) before passing it to metadataBase.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@src/app/`(main)/books/[id]/page.tsx:
- Around line 12-14: generateMetadata currently returns a generic fallback title
for all failures; update the generateMetadata function in
src/app/(main)/books/[id]/page.tsx to call notFound() (imported from
next/navigation) when the response status is 404 (res.status === 404) and also
when the parsed payload has no result (!data?.result), while preserving the
existing fallback behavior only for non-404 transport or server errors (res.ok
=== false && res.status !== 404) so those still return { title: "도서" }.
In `@src/app/`(main)/groups/[id]/bookcase/[bookId]/BookDetailPageClient.tsx:
- Around line 245-247: The catch block in BookDetailPageClient.tsx currently
uses catch (e: any) and reads e.message; change it to catch (e: unknown) and
narrow the type before accessing message: check instanceof Error to extract
e.message (or handle string/other types safely, e.g. fallback to String(e) or
JSON.stringify) and then pass that safe msg into toast.error; update the catch
block around the report/error path where the current const msg = e?.message ??
"" is defined.
In `@src/app/`(main)/groups/[id]/bookcase/[bookId]/meeting/MeetingPageClient.tsx:
- Around line 213-220: The memo selector selectedTeamMembers assumes
meetingData.teamMembers exists and calls .find() directly; change the guard to
verify teamMembers is present before using .find (e.g., check meetingData &&
meetingData.teamMembers or use meetingData.teamMembers?.find) so that when
teamMembers is undefined you return [] early; update the logic around
selectedTeamId, matchedGroup and members to safely handle the absent teamMembers
case (matchedGroup?.members ?? []) to avoid runtime crashes.
In `@src/app/`(main)/groups/[id]/notice/[noticeId]/NoticeDetailPageClient.tsx:
- Around line 38-40: isAdmin currently only checks meData?.staff which can miss
users whose club info returns only a status; update the isAdmin computation
(where useClubMeQuery is used and the isAdmin constant is defined) to match
NoticePageClient by treating either meData?.staff truthy OR meData?.myStatus ===
"STAFF" as admin, so admin UI/privileges remain consistent between the list and
detail pages.
In `@src/app/`(main)/groups/[id]/notice/NoticePageClient.tsx:
- Around line 34-41: The empty-state is computed from potentially undefined
data, causing isEmpty to be true while useClubNoticesQuery (noticesQuery) is
loading or errored; update the logic to only compute pinnedNotices, notices and
totalPages when noticesQuery.isSuccess is true (or check noticesQuery.status ===
'success') and otherwise treat the list as unknown (do not show the empty UI),
and add explicit handling for noticesQuery.isLoading (show loader) and
noticesQuery.isError (show error UI); adjust where isEmpty is used so it only
triggers when noticesQuery.isSuccess is true, referencing useClubNoticesQuery,
noticesQuery, pinnedNotices, notices, totalPages and isEmpty to locate the
changes.
In `@src/app/`(main)/groups/create/CreateGroupPageClient.tsx:
- Line 73: The duplicate-name check is using the raw clubName value while the
submission and pre-check logic use clubName.trim(), causing mismatches; compute
a trimmedName = clubName.trim() and use that everywhere: pass trimmedName into
useClubNameCheckQuery (instead of clubName), use trimmedName in the pre-check
conditions that currently reference clubName (lines ~116-124), call
nameQuery.refetch with trimmedName, and include trimmedName in the final
payload/submission (lines ~194-195 and the create/submit handler) so the query
key and submitted value match exactly.
In `@src/app/`(main)/groups/GroupsPageClient.tsx:
- Around line 165-173: In onSubmitSearch, remove the immediate call to
refetchSearch() so the update to appliedParams drives the query lifecycle
instead of triggering a stale request; keep the keyword extraction (q.trim()),
the guard that sets appliedParams to null when empty, and the
setAppliedParams({... outputFilter: mapCategoryToOutputFilter(category),
inputFilter: mapInputFilter(group, region), keyword }), but delete the
refetchSearch() invocation so the new query instance created by the
appliedParams change is the one that runs.
In `@src/app/`(main)/news/NewsPageClient.tsx:
- Around line 100-104: The FloatingFab click handler in NewsPageClient.tsx opens
an external site with window.open(EXTERNAL_LINKS.NEWS_FROM_URL, "_blank") which
allows reverse-tabnabbing; update the onClick to open the URL with the
noopener,noreferrer feature (e.g. window.open(EXTERNAL_LINKS.NEWS_FROM_URL,
"_blank", "noopener,noreferrer")) and optionally set newWindow.opener = null if
a window handle is returned, ensuring FloatingFab's onClick prevents the opened
page from accessing window.opener.
In `@src/app/`(main)/setting/logout/LogoutPageClient.tsx:
- Around line 13-21: The logout flow in performLogout can throw from
authService.logout() and leave the UI stuck; modify performLogout to ensure
queryClient.clear(), router.refresh(), and router.push("/") always run by
wrapping the authService.logout() call in a try/finally (or try/catch/finally)
so that even on error the cleanup and navigation occur; keep error handling
minimal (e.g., log or report the error) but always execute the finally block to
clear queries and navigate using router.refresh() and router.push("/").
In `@src/app/`(main)/setting/notifications/NotificationPageClient.tsx:
- Around line 9-25: The page currently renders toggle controls even when
useNotificationSettingsQuery failed or returned no settings; update the render
logic to check the query's error state and the presence of settings before
showing toggles. Specifically, use the query's isError (or check !settings)
along with isLoading to short-circuit rendering inside NotificationPageClient:
when isError or settings is falsy render an error/empty state message instead of
the toggles, and only render the toggle controls that call handleToggle (which
uses toggleSetting) when settings is defined and valid. Ensure
NotificationSettingType-based toggles read their isChecked from settings safely
(after the existence check) to avoid undefined values.
- Around line 73-79: The “모임 댓글/답글 알림” row is wired to
settings?.clubMeetingCreated and handleToggle("CLUB_MEETING_CREATED") but that
enum/value actually represents regular meeting creation; either (A) update the
row text and description to reflect meeting-creation semantics (e.g., change
title/description to "정기모임 생성 알림"/matching description) so the UI matches
NotificationSettingType CLUB_MEETING_CREATED, or (B) if the intent was to toggle
club comment/reply notifications, add a new NotificationSettingType value (e.g.,
CLUB_COMMENT_REPLY), add a corresponding field to NotificationSettings (e.g.,
clubCommentReply), update backend/schema mapping to persist it, and wire the row
to settings?.clubCommentReply and handleToggle("CLUB_COMMENT_REPLY"); locate and
change the symbols settings?.clubMeetingCreated and
handleToggle("CLUB_MEETING_CREATED") in NotificationPageClient.tsx plus update
NotificationSettingType and NotificationSettings types and any API handlers that
read/write these settings.
In `@src/app/`(main)/setting/password/PasswordChangePageClient.tsx:
- Around line 42-44: The onError handler in PasswordChangePageClient currently
types the error as any and directly accesses error.message; change the parameter
to unknown and add a narrow/guard before reading message (e.g., create a small
type-guard that checks typeof err === "object" && err !== null && "message" in
err && typeof (err as any).message === "string", or detect AxiosError/Fetch
error shapes), then call toast.error with the guarded message or fall back to
the default Korean text; update the onError signature and use the guard inside
the body so no explicit any remains.
In `@src/app/`(main)/setting/SettingsPageClient.tsx:
- Line 46: The map callback uses an explicit any (group.items.map((item: any) =>
...)), bypassing the SETTINGS_MENU as const typing; replace the any with the
concrete inferred type from SETTINGS_MENU, e.g. declare a type alias like
SettingItem = (typeof SETTINGS_MENU)[number]['items'][number] (or inline: (item:
(typeof SETTINGS_MENU)[number]['items'][number]) => ...) and update the map
signature to use that type so TypeScript preserves the literal types and
satisfies `@typescript-eslint/no-explicit-any`.
In `@src/app/`(main)/stories/[id]/edit/StoryEditPageClient.tsx:
- Around line 23-33: The useEffect in StoryEditPageClient sets description after
mount causing an extra render; instead initialize the description state directly
from the loaded story and remove the initialization effect and the
isDescriptionInitialized flag. Replace the useState for description to derive
its initial value from story (e.g., useState(() => story?.description ?? ""))
and delete the useEffect and
isDescriptionInitialized/setIsDescriptionInitialized usage so only the
description and setDescription remain.
In `@src/app/`(main)/stories/[id]/StoryDetailClient.tsx:
- Around line 32-47: The early return in StoryDetailClient causes a hooks-order
violation because hooks (useStoryDetailQuery, useToggleStoryLikeMutation,
useDeleteBookStoryMutation, useToggleFollowMutation, useAuthStore, useState) are
declared after the conditional return; fix it by moving all hook usage and
related state into a new child component (e.g., StoryDetailContent) that accepts
storyId: number, keep StoryDetailClient responsible only for extracting
params/id and doing the early-return check, and then render <StoryDetailContent
storyId={storyId} /> when id is valid; ensure the new component contains the
referenced symbols (useStoryDetailQuery, mutate: toggleLike,
deleteStory/isDeletePending, toggleFollow, isLoggedIn/openLoginModal,
isDeleteModalOpen/setIsDeleteModalOpen) and the rest of the UI logic.
In `@src/app/`(public)/find-account/FindAccountPageClient.tsx:
- Around line 29-34: The code in FindAccountPageClient is leaking PII by putting
data.email into router.push query; instead, before calling router.push in the
findEmail onSuccess handler, store the recovered email in ephemeral client state
(e.g., sessionStorage under a key like "foundEmail" or request an opaque backend
token) and then navigate to the result page without including email in the URL
(change the router.push call in findEmail to route to "/find-account/result"
with no email param). Update the result page client to read the email from
sessionStorage (with a safe fallback if absent) or exchange the opaque token for
the email, and remove any encodeURIComponent(data.email) usage in
FindAccountPageClient.
In `@src/app/`(public)/signup/[step]/SignupStepPageClient.tsx:
- Around line 22-49: The effect in SignupStepPageClient.tsx only sets isSocial
true and never resets it, causing stale social-signup state; update the
useEffect (which reads searchParams.get("isSocial"), computes
urlIsSocial/urlIsSocialFalse, storeIsSocial and shouldBeSocial) to explicitly
setIsSocial(false) when shouldBeSocial is false (and clear any social-derived
email via setEmail if it came from user?.email or searchParams.get("email") to
avoid carrying a social email into normal signup), and keep the existing
router.replace logic for urlIsSocialFalse and profile redirects when
shouldBeSocial is true.
---
Minor comments:
In `@src/app/`(main)/books/[id]/page.tsx:
- Around line 19-23: The route-level generateMetadata is replacing the parent
openGraph object and dropping shared fields; update generateMetadata in
src/app/(main)/books/[id]/page.tsx to merge the parent metadata by spreading
parent.openGraph into the returned openGraph and then override only the
book-specific fields (e.g., images, title, description) so shared keys like
siteName, locale, type, and url are preserved.
In `@src/app/`(main)/groups/[id]/bookcase/[bookId]/BookDetailPageClient.tsx:
- Around line 104-112: The useEffect handling searchParams currently ignores
invalid tab values in the URL; when searchParams.get("tab") returns a value that
is not isTabKey(tab) you should treat it like a missing param:
setActiveTab("meeting"), create a new URLSearchParams from searchParams,
set("tab","meeting"), and call router.replace(`${pathname}?${next.toString()}`,
{ scroll: false }) so the URL is normalized; keep the existing branch that only
sets activeTab for valid tabs but add this branch for the invalid-tab case (use
the same symbols: useEffect, searchParams, isTabKey, activeTab, router.replace,
pathname).
In `@src/app/`(main)/groups/[id]/bookcase/BookcasePageClient.tsx:
- Around line 76-82: The current guard in BookcasePageClient.tsx only checks
Number.isNaN(groupId) so values like 0, negative numbers or Infinity still pass
and render the wrong empty-state; change the validation to the same
positive/finite check used by the query: replace the NaN check with a guard like
if (!(Number.isFinite(groupId) && groupId > 0)) and return the "잘못된 모임 ID"
invalid-route UI when that condition is true (update the check around groupId in
the BookcasePageClient component).
In `@src/app/`(main)/groups/[id]/notice/NoticePageClient.tsx:
- Around line 43-49: The formatDate function currently parses ISO timestamps
with new Date(iso) which converts to local time and can shift the day; update
formatDate (in NoticePageClient.tsx) to format the server date in UTC (e.g., use
Date methods that read UTC like getUTCFullYear/getUTCMonth/getUTCDate) or simply
derive the yyyy-mm-dd from the ISO string (iso.slice(0,10)) and replace '-' with
'.' so list and detail pages always use the server date without local-time
shifts.
In `@src/app/`(main)/news/[id]/NewsDetailClient.tsx:
- Around line 24-33: The carousel uses news.imageUrls.length for rotation but
the UI renders a filtered list, so compute a single renderedImages array (e.g.,
validImageUrls or filteredImageUrls) from news.imageUrls (same filter used
around the render block) and use that array everywhere: in the useEffect guard,
in the setInterval modulo (setCurrentImageIndex(prev => (prev + 1) %
renderedImages.length)), and in the render logic that maps images (instead of
directly using news.imageUrls); also update the effect dependency to
[renderedImages.length] and ensure currentImageIndex is clamped/reset if
renderedImages becomes shorter than the current index.
In `@src/app/`(main)/profile/[nickname]/follows/NicknameFollowsClient.tsx:
- Around line 20-21: The code currently casts the query param directly which
allows invalid values to reach runtime; update the logic that computes
initialTab (and thus activeTab) to explicitly read searchParams.get("tab"),
check if it's exactly "follower" or "following", and only then use it—otherwise
default to "follower"; change the initializer around initialTab/activeTab (the
variables initialTab, activeTab, setActiveTab, and the useState call in
NicknameFollowsClient) to perform this explicit clamp so only the two supported
values are used at runtime.
In `@src/app/`(main)/profile/mypage/follows/MypageFollowsClient.tsx:
- Around line 18-19: The code seeds activeTab by directly casting
searchParams.get("tab") to the union which lets invalid strings slip through;
update the initialization to validate/normalize the incoming value from
searchParams.get("tab") so only "follower" or "following" are accepted
(otherwise default to "follower"). Do this by checking the raw string (from
searchParams.get("tab")) against the two allowed values (or by adding a small
helper like normalizeTab) and use the validated result when calling useState for
activeTab (and likewise use the same validation whenever parsing the tab query
elsewhere).
In `@src/app/`(main)/profile/mypage/MypageClient.tsx:
- Around line 15-22: The tab state is initialized from useSearchParams but later
changes to activeTab (const [activeTab, setActiveTab]) don't update the URL, so
sharing/refresh loses the current tab; fix by synchronizing activeTab and the
query param: either derive the rendered tab directly from useSearchParams
(remove local state) or keep activeTab but add an effect that updates
searchParams when activeTab changes (use setSearchParams or router.replace) and
also listen for searchParams changes to update activeTab (useEffect reading
useSearchParams). Update the logic around initialTab, activeTab, setActiveTab
and any handlers that change tabs (also apply the same pattern referenced around
lines 36-40) so the URL and component state remain consistent.
In `@src/app/`(main)/search/SearchPageClient.tsx:
- Around line 33-39: The total-count UI is showing "총 0개" during the initial
fetch because useInfiniteBookSearchQuery's isLoading is ignored and
searchResults is computed empty; update the render logic in SearchPageClient
(the area using useInfiniteBookSearchQuery, searchResults, and total count) to
respect isLoading: when isLoading is true show a loading state (or suppress the
total count) instead of computing/printing total from an empty searchResults,
and only compute/display the aggregated total from data.pages (or from
searchResults) once isLoading is false (or data exists); ensure similar fixes
apply to the other occurrences flagged (lines ~100-104) where total or
empty-state is derived without checking isLoading.
- Around line 21-25: handleSearch currently only routes when searchValue.trim()
is non-empty so clearing the input and pressing Enter leaves the old q param;
update handleSearch to call router.push('/search') when searchValue.trim() is
empty (and keep
router.push(`/search?q=${encodeURIComponent(searchValue.trim())}`) when
non-empty) so the URL and displayed results are cleared; modify the logic in the
handleSearch function that uses searchValue and router.push accordingly.
In `@src/app/`(public)/find-password/FindPasswordPageClient.tsx:
- Around line 21-27: The handler handleSendTempPassword currently allows
whitespace-only or malformed emails to reach sendTempPassword; update it to trim
the email (const normalizedEmail = email?.trim()) and run a basic email format
check (e.g., simple regex) before calling sendTempPassword; if normalizedEmail
is empty or fails validation, call toast.error("이메일을 입력해주세요." or a
format-specific message) and return, otherwise call
sendTempPassword(normalizedEmail, {...}) so the mutation always receives a
normalized, validated address.
---
Nitpick comments:
In `@src/app/landing/LandingPageClient.tsx`:
- Around line 16-18: The current useEffect in LandingPageClient.tsx
unconditionally calls localStorage.setItem(LANDING_KEY, String(Date.now())) on
every mount; decide whether you want first-visit-only behavior and, if so,
change the effect to check localStorage.getItem(LANDING_KEY) before setting it
(i.e., only write the timestamp if the key is missing); otherwise leave the
useEffect as-is if overwriting on every visit is intentional.
In `@src/app/layout.tsx`:
- Line 25: The metadataBase value in src/app/layout.tsx is hardcoded to
"https://www.checkmo.co.kr"; update metadataBase to read from an environment
variable (e.g. process.env.NEXT_PUBLIC_BASE_URL) with a safe fallback to the
existing production URL so different deployments (dev/staging/prod) can provide
their own base URL; locate the metadataBase assignment in layout.tsx and replace
the literal new URL(...) with logic that constructs the URL from the env var (or
falls back) before passing it to metadataBase.
In `@src/app/robots.ts`:
- Line 24: The robots configuration hardcodes sitemap:
"https://www.checkmo.co.kr/sitemap.xml", which ties it to production; update the
sitemap value to derive from the app's runtime base URL (e.g., reuse
metadataBase from the root layout or construct from an environment/runtime
variable) so it adapts for dev/staging; locate the sitemap property in
src/app/robots.ts and replace the literal URL with a dynamic value built from
the app's metadataBase or process/env variable (ensuring a trailing / and
appending "sitemap.xml") to produce the correct full URL at runtime.
- Around line 9-21: The disallow array in the MetadataRoute.Robots configuration
(the disallow property in src/app/robots.ts) contains literal wildcard patterns
like "/groups/*/admin" and "/stories/*/edit" which Next.js will emit verbatim
into robots.txt and may not be honored by crawlers; update the disallow entries
to only use concrete path prefixes or validated patterns supported by your
target crawlers (e.g., replace "/groups/*/admin" with "/groups/" or explicit
paths, or generate multiple concrete Disallow entries), or add a comment and
tests explaining which crawlers will interpret "*" if you intend to keep them;
locate and modify the disallow array in the MetadataRoute.Robots definition to
apply the chosen approach.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro
Run ID: ec016563-5c9f-482d-a92a-90ed59c65def
⛔ Files ignored due to path filters (1)
public/og.pngis excluded by!**/*.png
📒 Files selected for processing (85)
src/app/(main)/books/[id]/BookDetailClient.tsxsrc/app/(main)/books/[id]/page.tsxsrc/app/(main)/groups/GroupsPageClient.tsxsrc/app/(main)/groups/[id]/GroupDetailClient.tsxsrc/app/(main)/groups/[id]/bookcase/BookcasePageClient.tsxsrc/app/(main)/groups/[id]/bookcase/[bookId]/BookDetailPageClient.tsxsrc/app/(main)/groups/[id]/bookcase/[bookId]/meeting/MeetingPageClient.tsxsrc/app/(main)/groups/[id]/bookcase/[bookId]/meeting/page.tsxsrc/app/(main)/groups/[id]/bookcase/[bookId]/page.tsxsrc/app/(main)/groups/[id]/bookcase/page.tsxsrc/app/(main)/groups/[id]/notice/NoticePageClient.tsxsrc/app/(main)/groups/[id]/notice/[noticeId]/NoticeDetailPageClient.tsxsrc/app/(main)/groups/[id]/notice/[noticeId]/page.tsxsrc/app/(main)/groups/[id]/notice/page.tsxsrc/app/(main)/groups/[id]/page.tsxsrc/app/(main)/groups/create/CreateGroupPageClient.tsxsrc/app/(main)/groups/create/page.tsxsrc/app/(main)/groups/page.tsxsrc/app/(main)/home/HomePageClient.tsxsrc/app/(main)/home/page.tsxsrc/app/(main)/news/NewsPageClient.tsxsrc/app/(main)/news/[id]/NewsDetailClient.tsxsrc/app/(main)/news/[id]/page.tsxsrc/app/(main)/news/page.tsxsrc/app/(main)/profile/[nickname]/ProfileClient.tsxsrc/app/(main)/profile/[nickname]/follows/NicknameFollowsClient.tsxsrc/app/(main)/profile/[nickname]/follows/page.tsxsrc/app/(main)/profile/[nickname]/page.tsxsrc/app/(main)/profile/mypage/MypageClient.tsxsrc/app/(main)/profile/mypage/follows/MypageFollowsClient.tsxsrc/app/(main)/profile/mypage/follows/page.tsxsrc/app/(main)/profile/mypage/layout.tsxsrc/app/(main)/profile/mypage/page.tsxsrc/app/(main)/search/SearchPageClient.tsxsrc/app/(main)/search/page.tsxsrc/app/(main)/setting/SettingsLayoutClient.tsxsrc/app/(main)/setting/SettingsPageClient.tsxsrc/app/(main)/setting/account-status/AccountStatusPageClient.tsxsrc/app/(main)/setting/account-status/page.tsxsrc/app/(main)/setting/block/BlockPageClient.tsxsrc/app/(main)/setting/block/page.tsxsrc/app/(main)/setting/email/EmailChangePageClient.tsxsrc/app/(main)/setting/email/page.tsxsrc/app/(main)/setting/layout.tsxsrc/app/(main)/setting/logout/LogoutPageClient.tsxsrc/app/(main)/setting/logout/page.tsxsrc/app/(main)/setting/news/MyNewsPageClient.tsxsrc/app/(main)/setting/news/page.tsxsrc/app/(main)/setting/notifications/NotificationPageClient.tsxsrc/app/(main)/setting/notifications/page.tsxsrc/app/(main)/setting/page.tsxsrc/app/(main)/setting/password/PasswordChangePageClient.tsxsrc/app/(main)/setting/password/page.tsxsrc/app/(main)/setting/profile/ProfileEditPageClient.tsxsrc/app/(main)/setting/profile/page.tsxsrc/app/(main)/setting/report/ReportPageClient.tsxsrc/app/(main)/setting/report/page.tsxsrc/app/(main)/setting/support/SupportPageClient.tsxsrc/app/(main)/setting/support/page.tsxsrc/app/(main)/setting/terms/page.tsxsrc/app/(main)/setting/version/page.tsxsrc/app/(main)/stories/StoriesPageClient.tsxsrc/app/(main)/stories/[id]/StoryDetailClient.tsxsrc/app/(main)/stories/[id]/edit/StoryEditPageClient.tsxsrc/app/(main)/stories/[id]/edit/page.tsxsrc/app/(main)/stories/[id]/page.tsxsrc/app/(main)/stories/new/StoryNewPageClient.tsxsrc/app/(main)/stories/new/page.tsxsrc/app/(main)/stories/page.tsxsrc/app/(public)/find-account/FindAccountPageClient.tsxsrc/app/(public)/find-account/page.tsxsrc/app/(public)/find-account/result/FindAccountResultPageClient.tsxsrc/app/(public)/find-account/result/page.tsxsrc/app/(public)/find-password/FindPasswordPageClient.tsxsrc/app/(public)/find-password/page.tsxsrc/app/(public)/signup/[step]/SignupStepPageClient.tsxsrc/app/(public)/signup/[step]/page.tsxsrc/app/(public)/signup/page.tsxsrc/app/landing/LandingPageClient.tsxsrc/app/landing/page.tsxsrc/app/layout.tsxsrc/app/robots.tssrc/components/base-ui/Landing/LandingChatSection.tsxsrc/components/base-ui/Landing/LandingHero.tsxsrc/components/base-ui/Landing/LandingNav.tsx
| if (!res.ok) return { title: "도서" }; | ||
| const data = await res.json(); | ||
| if (!data?.result) return { title: "도서" }; |
There was a problem hiding this comment.
🧩 Analysis chain
🏁 Script executed:
#!/bin/bash
set -euo pipefail
# Inspect the target file and relevant surrounding code
FILE="src/app/(main)/books/[id]/page.tsx"
if [ ! -f "$FILE" ]; then
echo "Missing file: $FILE" >&2
exit 1
fi
echo "== File: $FILE =="
wc -l "$FILE"
nl -ba "$FILE" | sed -n '1,120p'
# Extract any generateMetadata / notFound usage in this file
echo
echo "== generateMetadata usage =="
rg -n "generateMetadata|notFound\\(" "$FILE" || true
# Show any other notFound usage in related files under same route
echo
echo "== Related route files (if any) =="
ls -la "src/app/(main)/books/[id]" || true
for f in src/app/(main)/books/[id]/page.tsx src/app/(main)/books/[id]/layout.tsx src/app/(main)/books/[id]/loading.tsx src/app/(main)/books/[id]/error.tsx 2>/dev/null; do
if [ -f "$f" ]; then
echo
echo "---- $f ----"
rg -n "generateMetadata|notFound\\(" "$f" || true
fi
doneRepository: checkmo2025/FE
Length of output: 205
🏁 Script executed:
#!/bin/bash
set -euo pipefail
FILE="src/app/(main)/books/[id]/page.tsx"
if [ ! -f "$FILE" ]; then
echo "Missing file: $FILE" >&2
exit 1
fi
echo "== File: $FILE =="
wc -l "$FILE"
nl -ba "$FILE" | sed -n '1,200p'
echo
echo "== generateMetadata / notFound usage =="
rg -n "generateMetadata|notFound\\(" "$FILE" || true
echo
echo "== Route directory listing =="
ls -la "src/app/(main)/books/[id]" || trueRepository: checkmo2025/FE
Length of output: 205
🏁 Script executed:
#!/bin/bash
set -euo pipefail
FILE="src/app/(main)/books/[id]/page.tsx"
if [ ! -f "$FILE" ]; then
echo "Missing file: $FILE" >&2
exit 1
fi
echo "== File: $FILE =="
wc -l "$FILE"
nl -ba "$FILE" | sed -n '1,200p'
echo
echo "== generateMetadata / notFound usage =="
rg -n "generateMetadata|notFound\\(" "$FILE" || true
echo
echo "== Route directory listing =="
ls -la "src/app/(main)/books/[id]" || trueRepository: checkmo2025/FE
Length of output: 205
🏁 Script executed:
#!/bin/bash
set -euo pipefail
FILE="src/app/(main)/books/[id]/page.tsx"
echo "== Basic checks =="
ls -la "$FILE"
wc -l "$FILE"
echo
echo "== File contents (with line numbers) =="
awk '{printf "%4d %s\n", NR, $0}' "$FILE" | sed -n '1,200p'
echo
echo "== Route directory listing =="
ls -la "src/app/(main)/books/[id]" || true
echo
echo "== Search for generateMetadata / notFound in this route =="
rg -n "generateMetadata|notFound\\(" "src/app/(main)/books/[id]" || trueRepository: checkmo2025/FE
Length of output: 1803
🌐 Web query:
Next.js App Router notFound() inside generateMetadata support
💡 Result:
Yes—next.js App Router supports calling notFound inside generateMetadata. 1) What the official docs say The generateMetadata API reference explicitly states that Next.js “redirect and notFound Next.js methods can also be used inside generateMetadata.” [1] 2) Practical implication (what you’ll see) When you trigger notFound from generateMetadata, Next.js can still resolve metadata/streaming behavior in a way that may not always produce the HTTP 404 status in every streaming configuration. Community discussions/issues report scenarios where a 200 response is returned when streaming boundaries/loader components are present. [2] (So “notFound inside generateMetadata is supported,” but “it will always produce an HTTP 404 status code” is not guaranteed in all cases.) [2] 3) If you’re trying to put metadata on not-found pages There’s an additional nuance: exporting metadata/generateMetadata from nested not-found boundaries was addressed as a feature in the Next.js repo (“support metadata/generateMetadata in nested not-found pages”). [3] Recommended pattern - Use notFound inside generateMetadata when the metadata-producing data doesn’t exist. [1] - If you require strict 404 HTTP semantics for SEO/crawlers, verify the response status in your specific routing/layout/loading setup (especially when streaming is involved). [2] If you share your app structure (where the dynamic segment is, and where loading.tsx/layout.tsx are), I can tell you what behavior to expect for HTTP status vs metadata rendering in that exact setup.
Citations:
- 1: https://nextjs.org/docs/14/app/api-reference/functions/generate-metadata
- 2: App Router: returning notFound() from generateMetadata does not change the response status code to 404 if there's a loading.tsx component vercel/next.js#75563
- 3: feat: support metadata/generateMetadata in nested not-found pages vercel/next.js#90021
Return a real 404 for missing books.
src/app/(main)/books/[id]/page.tsx’s generateMetadata currently returns { title: "도서" } for any !res.ok and for empty !data?.result, so missing/invalid /books/[id] won’t trigger Next.js’s not-found flow until the client handles the error. Call notFound() for explicit res.status === 404 and for empty/absent results, and keep the fallback for non-404 and transport failures. (nextjs.org)
Suggested direction
+import { notFound } from "next/navigation";
import type { Metadata } from "next";
import BookDetailClient from "./BookDetailClient";
@@
try {
const res = await fetch(`${process.env.NEXT_PUBLIC_API_URL}/books/${id}`, {
next: { revalidate: 3600 },
});
- if (!res.ok) return { title: "도서" };
+ if (res.status === 404) notFound();
+ if (!res.ok) return { title: "도서" };
const data = await res.json();
- if (!data?.result) return { title: "도서" };
+ if (!data?.result) notFound();🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@src/app/`(main)/books/[id]/page.tsx around lines 12 - 14, generateMetadata
currently returns a generic fallback title for all failures; update the
generateMetadata function in src/app/(main)/books/[id]/page.tsx to call
notFound() (imported from next/navigation) when the response status is 404
(res.status === 404) and also when the parsed payload has no result
(!data?.result), while preserving the existing fallback behavior only for
non-404 transport or server errors (res.ok === false && res.status !== 404) so
those still return { title: "도서" }.
| } catch (e: any) { | ||
| const msg = e?.message ?? ""; | ||
| toast.error(msg || "신고 접수에 실패했습니다."); |
There was a problem hiding this comment.
Drop the explicit any in the report error path.
This is the ESLint error already flagged for the file and can block merge if lint runs in CI. Catch as unknown and narrow before reading message.
🧰 Tools
🪛 ESLint
[error] 245-245: Unexpected any. Specify a different type.
(@typescript-eslint/no-explicit-any)
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@src/app/`(main)/groups/[id]/bookcase/[bookId]/BookDetailPageClient.tsx around
lines 245 - 247, The catch block in BookDetailPageClient.tsx currently uses
catch (e: any) and reads e.message; change it to catch (e: unknown) and narrow
the type before accessing message: check instanceof Error to extract e.message
(or handle string/other types safely, e.g. fallback to String(e) or
JSON.stringify) and then pass that safe msg into toast.error; update the catch
block around the report/error path where the current const msg = e?.message ??
"" is defined.
| const selectedTeamMembers = useMemo(() => { | ||
| if (!meetingData || selectedTeamId === null) return []; | ||
|
|
||
| const matchedGroup = meetingData.teamMembers.find( | ||
| (group) => group.teamKey.teamId === selectedTeamId | ||
| ); | ||
|
|
||
| return matchedGroup?.members ?? []; |
There was a problem hiding this comment.
Guard teamMembers before calling .find().
Earlier in this component meetingData.teamMembers is treated as optional, but Line 216 assumes it is always defined. A response without teamMembers will crash the page here instead of showing the empty-team fallback.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@src/app/`(main)/groups/[id]/bookcase/[bookId]/meeting/MeetingPageClient.tsx
around lines 213 - 220, The memo selector selectedTeamMembers assumes
meetingData.teamMembers exists and calls .find() directly; change the guard to
verify teamMembers is present before using .find (e.g., check meetingData &&
meetingData.teamMembers or use meetingData.teamMembers?.find) so that when
teamMembers is undefined you return [] early; update the logic around
selectedTeamId, matchedGroup and members to safely handle the absent teamMembers
case (matchedGroup?.members ?? []) to avoid runtime crashes.
| const { data: meData } = useClubMeQuery(clubId); | ||
|
|
||
| const isAdmin = !!meData?.staff; |
There was a problem hiding this comment.
Use the same admin check as the notice list page.
Here isAdmin only looks at meData?.staff, but NoticePageClient also falls back to me.myStatus === "STAFF". If this hook returns only myStatus, admins lose edit/comment privileges on the detail page while still seeing admin UI on the list page.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@src/app/`(main)/groups/[id]/notice/[noticeId]/NoticeDetailPageClient.tsx
around lines 38 - 40, isAdmin currently only checks meData?.staff which can miss
users whose club info returns only a status; update the isAdmin computation
(where useClubMeQuery is used and the isAdmin constant is defined) to match
NoticePageClient by treating either meData?.staff truthy OR meData?.myStatus ===
"STAFF" as admin, so admin UI/privileges remain consistent between the list and
detail pages.
| const noticesQuery = useClubNoticesQuery(clubId, currentPage); | ||
| const data = noticesQuery.data; | ||
|
|
||
| const pinnedNotices = data?.pinnedNotices ?? []; | ||
| const notices = data?.normalNotices.notices ?? []; | ||
| const totalPages = Math.max(data?.normalNotices.totalPages ?? 0, 1); | ||
|
|
||
| const isEmpty = pinnedNotices.length === 0 && notices.length === 0; |
There was a problem hiding this comment.
Don't render the empty state before the query settles.
data is undefined during loading and on errors, so isEmpty becomes true and the page shows “아직 등록된 공지사항이 없습니다.” even when notices are still loading or the request failed. Gate the empty state on a successful fetch and add an explicit error state.
Also applies to: 74-80
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@src/app/`(main)/groups/[id]/notice/NoticePageClient.tsx around lines 34 - 41,
The empty-state is computed from potentially undefined data, causing isEmpty to
be true while useClubNoticesQuery (noticesQuery) is loading or errored; update
the logic to only compute pinnedNotices, notices and totalPages when
noticesQuery.isSuccess is true (or check noticesQuery.status === 'success') and
otherwise treat the list as unknown (do not show the empty UI), and add explicit
handling for noticesQuery.isLoading (show loader) and noticesQuery.isError (show
error UI); adjust where isEmpty is used so it only triggers when
noticesQuery.isSuccess is true, referencing useClubNoticesQuery, noticesQuery,
pinnedNotices, notices, totalPages and isEmpty to locate the changes.
|
|
||
| {/* 세부 메뉴 리스트 (Frame 2087328520) */} | ||
| <div className="flex flex-col items-start gap-[2px] self-stretch"> | ||
| {group.items.map((item: any) => { |
There was a problem hiding this comment.
🧩 Analysis chain
🏁 Script executed:
#!/bin/bash
set -euo pipefail
# Locate the target file and show the exact line(s) around 46
FILE="src/app/(main)/setting/SettingsPageClient.tsx"
echo "== File exists? =="
ls -l "$FILE" || true
echo
echo "== Context around line 46 =="
nl -ba "$FILE" | sed -n '35,65p'
echo
# Find definition of SETTINGS_MENU (and any related types) in the repo
echo "== Find SETTINGS_MENU definition/usages =="
rg -n "SETTINGS_MENU" -S . --hidden || true
echo
# Show the file(s) where SETTINGS_MENU is defined (best effort: first matches)
# If multiple definitions, show the first few contexts.
matches=$(rg -n "SETTINGS_MENU" -S . --hidden | head -n 20 || true)
echo "== First SETTINGS_MENU matches (preview) =="
echo "$matches"
echo
# Also check whether the lint rule is configured
echo "== Check for `@typescript-eslint/no-explicit-any` configuration =="
rg -n "no-explicit-any" -S . --hidden || true
echo
# If ESLint config files exist, print them (lightly) for rule context
for f in .eslintrc* eslint.config.* package.json .*/eslint.config.*; do
if [ -f "$f" ]; then echo "== $f =="; nl -ba "$f" | head -n 120; echo; fi
doneRepository: checkmo2025/FE
Length of output: 264
🏁 Script executed:
#!/bin/bash
set -euo pipefail
FILE="src/app/(main)/setting/SettingsPageClient.tsx"
echo "== Context around line 46 (using cat -n) =="
# show a small window around the referenced line
cat -n "$FILE" | sed -n '35,65p'
echo
echo "== Find SETTINGS_MENU definition/usages =="
rg -n "SETTINGS_MENU" -S . --hidden || true
echo
echo "== Check for `@typescript-eslint/no-explicit-any` configuration =="
rg -n "no-explicit-any" -S . --hidden || true
echo
echo "== Look for ESLint config files (light preview) =="
for f in .eslintrc* eslint.config.* package.json .*/eslint.config.*; do
if [ -f "$f" ]; then
echo "== $f =="
cat -n "$f" | head -n 120
echo
fi
doneRepository: checkmo2025/FE
Length of output: 4733
🏁 Script executed:
#!/bin/bash
set -euo pipefail
# 1) Inspect SETTINGS_MENU definition
FILE_CONST="src/constants/setting/setting.ts"
echo "== SETTINGS_MENU definition =="
wc -l "$FILE_CONST"
sed -n '1,220p' "$FILE_CONST"
echo
# 2) Inspect SettingsPageClient around imports + the mapping block
FILE_PAGE="src/app/(main)/setting/SettingsPageClient.tsx"
echo "== SettingsPageClient imports + surrounding context =="
wc -l "$FILE_PAGE"
sed -n '1,120p' "$FILE_PAGE"
echo
# 3) Search for other explicit-any in repo to gauge lint usage
echo "== Search for ': any' occurrences =="
rg -n --hidden ":\s*any\b" src | head -n 50 || true
echo
# 4) Check whether this file (or project) disables the rule anywhere
echo "== Search for eslint-disable-next-line `@typescript-eslint/no-explicit-any` =="
rg -n --hidden "eslint-disable-next-line\s+@typescript-eslint/no-explicit-any" src || trueRepository: checkmo2025/FE
Length of output: 8302
🌐 Web query:
eslint-config-next typescript no-explicit-any severity
💡 Result:
In eslint-config-next’s TypeScript setup, the TypeScript ESLint rule that controls “no-explicit-any” is @typescript-eslint/no-explicit-any. The rule’s ESLint severity is defined by how it’s configured in the active config (e.g., next/typescript extends plugin:@typescript-eslint/recommended). The typescript-eslint docs describe the rule and show it being set to "error" as an example configuration: "@typescript-eslint/no-explicit-any": "error" [1]. So, the severity you will see when using eslint-config-next/typescript (the default template behavior) is "error" for @typescript-eslint/no-explicit-any, because the underlying rules come from plugin:@typescript-eslint/recommended [2].
Citations:
- 1: https://typescript-eslint.io/rules/no-explicit-any
- 2: https://nextjs.im/docs/app/api-reference/config/eslint/
Remove explicit any from settings menu item mapping
In src/app/(main)/setting/SettingsPageClient.tsx line 46, group.items.map((item: any) => bypasses the SETTINGS_MENU as const typing and trips @typescript-eslint/no-explicit-any.
Proposed fix
- {group.items.map((item: any) => {
+ {group.items.map((item) => {+type SettingMenuItem = (typeof SETTINGS_MENU)[number]["items"][number];
...
- {group.items.map((item) => {
+ {group.items.map((item: SettingMenuItem) => {📝 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.
| {group.items.map((item: any) => { | |
| {group.items.map((item) => { |
🧰 Tools
🪛 ESLint
[error] 46-46: Unexpected any. Specify a different type.
(@typescript-eslint/no-explicit-any)
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@src/app/`(main)/setting/SettingsPageClient.tsx at line 46, The map callback
uses an explicit any (group.items.map((item: any) => ...)), bypassing the
SETTINGS_MENU as const typing; replace the any with the concrete inferred type
from SETTINGS_MENU, e.g. declare a type alias like SettingItem = (typeof
SETTINGS_MENU)[number]['items'][number] (or inline: (item: (typeof
SETTINGS_MENU)[number]['items'][number]) => ...) and update the map signature to
use that type so TypeScript preserves the literal types and satisfies
`@typescript-eslint/no-explicit-any`.
| const [description, setDescription] = useState(""); | ||
| const [isDescriptionInitialized, setIsDescriptionInitialized] = useState(false); | ||
|
|
||
| // 초기 description 세팅 (데이터 로드 후 한 번만) | ||
| useEffect(() => { | ||
| if (story && !isDescriptionInitialized) { | ||
| setDescription(story.description); | ||
| setIsDescriptionInitialized(true); | ||
| } | ||
| }, [story, isDescriptionInitialized]); | ||
|
|
There was a problem hiding this comment.
🧩 Analysis chain
🏁 Script executed:
#!/bin/bash
set -euo pipefail
# Locate and print the relevant file section
FILE="src/app/(main)/stories/[id]/edit/StoryEditPageClient.tsx"
if [ ! -f "$FILE" ]; then
echo "Missing file: $FILE"
exit 1
fi
echo "== File: $FILE =="
wc -l "$FILE"
nl -ba "$FILE" | sed -n '1,120p'Repository: checkmo2025/FE
Length of output: 250
🏁 Script executed:
#!/bin/bash
set -euo pipefail
FILE="src/app/(main)/stories/[id]/edit/StoryEditPageClient.tsx"
echo "== File: $FILE =="
wc -l "$FILE"
echo "== Lines 1-120 =="
sed -n '1,120p' "$FILE" | cat -n | head -n 120
echo "== Lines 15-60 =="
sed -n '15,60p' "$FILE" | cat -n | head -n 60Repository: checkmo2025/FE
Length of output: 6854
🏁 Script executed:
#!/bin/bash
set -euo pipefail
rg -n "setDescription|onChange=.*setDescription|description\b" "src/app/(main)/stories/[id]/edit/StoryEditPageClient.tsx"Repository: checkmo2025/FE
Length of output: 477
🏁 Script executed:
#!/bin/bash
set -euo pipefail
rg -n "set-state-in-effect" . || true
rg -n "react-hooks" .eslintrc* package.json */.eslintrc* 2>/dev/null || true
ls -la | headRepository: checkmo2025/FE
Length of output: 1765
🏁 Script executed:
#!/bin/bash
set -euo pipefail
# Search lint rule definitions
rg -n "set-state-in-effect|react-hooks" eslint.config.mjs package.json . || true
# Show relevant config sections
echo "== eslint.config.mjs (top 200 lines) =="
sed -n '1,200p' eslint.config.mjs | cat -nRepository: checkmo2025/FE
Length of output: 2284
🏁 Script executed:
#!/bin/bash
set -euo pipefail
FILE="src/app/(main)/stories/[id]/edit/StoryEditPageClient.tsx"
echo "== Relevant input usage (lines 120-190) =="
sed -n '120,210p' "$FILE" | cat -n | head -n 120
# Find and show useStoryDetailQuery implementation
echo "== Locating useStoryDetailQuery =="
rg -n "useStoryDetailQuery" -S src | head -n 20
echo "== Showing hook implementation =="
HOOK_FILE="$(rg -n "export function useStoryDetailQuery|export const useStoryDetailQuery|function useStoryDetailQuery|useStoryDetailQuery\s*=" -S src -g'*.ts' -g'*.tsx' | head -n 1 | cut -d: -f1 || true)"
echo "Hook file: $HOOK_FILE"
if [ -n "${HOOK_FILE:-}" ] && [ -f "$HOOK_FILE" ]; then
sed -n '1,200p' "$HOOK_FILE" | cat -n
fiRepository: checkmo2025/FE
Length of output: 9890
Remove setDescription initialization inside useEffect in StoryEditPageClient
src/app/(main)/stories/[id]/edit/StoryEditPageClient.tsx initializes description via useEffect (setDescription(story.description)), triggering react-hooks/set-state-in-effect and adding an avoidable extra render.
Suggested fix
- const [description, setDescription] = useState("");
- const [isDescriptionInitialized, setIsDescriptionInitialized] = useState(false);
-
- // 초기 description 세팅 (데이터 로드 후 한 번만)
- useEffect(() => {
- if (story && !isDescriptionInitialized) {
- setDescription(story.description);
- setIsDescriptionInitialized(true);
- }
- }, [story, isDescriptionInitialized]);
+ const [editedDescription, setEditedDescription] = useState<string | null>(null);
+ const description = editedDescription ?? story?.description ?? "";- onChange={(e) => setDescription(e.target.value)}
+ onChange={(e) => setEditedDescription(e.target.value)}🧰 Tools
🪛 ESLint
[error] 29-29: Error: Calling setState synchronously within an effect can trigger cascading renders
Effects are intended to synchronize state between React and external systems such as manually updating the DOM, state management libraries, or other platform APIs. In general, the body of an effect should do one or both of the following:
- Update external systems with the latest state from React.
- Subscribe for updates from some external system, calling setState in a callback function when external state changes.
Calling setState synchronously within an effect body causes cascading renders that can hurt performance, and is not recommended. (https://react.dev/learn/you-might-not-need-an-effect).
/home/jailuser/git/src/app/(main)/stories/[id]/edit/StoryEditPageClient.tsx:29:7
27 | useEffect(() => {
28 | if (story && !isDescriptionInitialized) {
29 | setDescription(story.description);
| ^^^^^^^^^^^^^^ Avoid calling setState() directly within an effect
30 | setIsDescriptionInitialized(true);
31 | }
32 | }, [story, isDescriptionInitialized]);
(react-hooks/set-state-in-effect)
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@src/app/`(main)/stories/[id]/edit/StoryEditPageClient.tsx around lines 23 -
33, The useEffect in StoryEditPageClient sets description after mount causing an
extra render; instead initialize the description state directly from the loaded
story and remove the initialization effect and the isDescriptionInitialized
flag. Replace the useState for description to derive its initial value from
story (e.g., useState(() => story?.description ?? "")) and delete the useEffect
and isDescriptionInitialized/setIsDescriptionInitialized usage so only the
description and setDescription remain.
| if (!id || isNaN(storyId)) { | ||
| return ( | ||
| <div className="flex flex-col items-center justify-center min-h-[400px]"> | ||
| <h2 className="text-2xl font-bold text-Gray-7 mb-4">잘못된 접근</h2> | ||
| <p className="text-Gray-5">올바른 경로로 접근해주세요.</p> | ||
| </div> | ||
| ); | ||
| } | ||
|
|
||
| const { data: story, isLoading, isError, error } = useStoryDetailQuery(storyId); | ||
| const { mutate: toggleLike } = useToggleStoryLikeMutation(); | ||
| const { mutate: deleteStory, isPending: isDeletePending } = useDeleteBookStoryMutation(); | ||
| const { mutate: toggleFollow } = useToggleFollowMutation(); | ||
| const { isLoggedIn, openLoginModal } = useAuthStore(); | ||
|
|
||
| const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false); |
There was a problem hiding this comment.
Fix React hook order violation caused by early return.
In StoryDetailClient, the return inside if (!id || isNaN(storyId)) (around Lines 32–37) happens before useStoryDetailQuery, mutation hooks (useToggleStoryLikeMutation, useDeleteBookStoryMutation, useToggleFollowMutation), useAuthStore, and useState—so those hooks are skipped on some renders, breaking React’s rules of hooks.
Suggested refactor
export default function StoryDetailClient() {
const params = useParams();
const id = params?.id as string;
const storyId = Number(id);
if (!id || isNaN(storyId)) {
return (
<div className="flex flex-col items-center justify-center min-h-[400px]">
<h2 className="text-2xl font-bold text-Gray-7 mb-4">잘못된 접근</h2>
<p className="text-Gray-5">올바른 경로로 접근해주세요.</p>
</div>
);
}
return <StoryDetailContent storyId={storyId} />;
}
function StoryDetailContent({ storyId }: { storyId: number }) {
const { data: story, isLoading, isError, error } = useStoryDetailQuery(storyId);
const { mutate: toggleLike } = useToggleStoryLikeMutation();
const { mutate: deleteStory, isPending: isDeletePending } = useDeleteBookStoryMutation();
const { mutate: toggleFollow } = useToggleFollowMutation();
const { isLoggedIn, openLoginModal } = useAuthStore();
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
// ...rest of the component
}🧰 Tools
🪛 ESLint
[error] 41-41: React Hook "useStoryDetailQuery" is called conditionally. React Hooks must be called in the exact same order in every component render. Did you accidentally call a React Hook after an early return?
(react-hooks/rules-of-hooks)
[error] 42-42: React Hook "useToggleStoryLikeMutation" is called conditionally. React Hooks must be called in the exact same order in every component render. Did you accidentally call a React Hook after an early return?
(react-hooks/rules-of-hooks)
[error] 43-43: React Hook "useDeleteBookStoryMutation" is called conditionally. React Hooks must be called in the exact same order in every component render. Did you accidentally call a React Hook after an early return?
(react-hooks/rules-of-hooks)
[error] 44-44: React Hook "useToggleFollowMutation" is called conditionally. React Hooks must be called in the exact same order in every component render. Did you accidentally call a React Hook after an early return?
(react-hooks/rules-of-hooks)
[error] 45-45: React Hook "useAuthStore" is called conditionally. React Hooks must be called in the exact same order in every component render. Did you accidentally call a React Hook after an early return?
(react-hooks/rules-of-hooks)
[error] 47-47: React Hook "useState" is called conditionally. React Hooks must be called in the exact same order in every component render. Did you accidentally call a React Hook after an early return?
(react-hooks/rules-of-hooks)
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@src/app/`(main)/stories/[id]/StoryDetailClient.tsx around lines 32 - 47, The
early return in StoryDetailClient causes a hooks-order violation because hooks
(useStoryDetailQuery, useToggleStoryLikeMutation, useDeleteBookStoryMutation,
useToggleFollowMutation, useAuthStore, useState) are declared after the
conditional return; fix it by moving all hook usage and related state into a new
child component (e.g., StoryDetailContent) that accepts storyId: number, keep
StoryDetailClient responsible only for extracting params/id and doing the
early-return check, and then render <StoryDetailContent storyId={storyId} />
when id is valid; ensure the new component contains the referenced symbols
(useStoryDetailQuery, mutate: toggleLike, deleteStory/isDeletePending,
toggleFollow, isLoggedIn/openLoginModal, isDeleteModalOpen/setIsDeleteModalOpen)
and the rest of the UI logic.
| findEmail( | ||
| { name, phoneNumber: phone.replace(/-/g, "") }, | ||
| { | ||
| onSuccess: (data) => { | ||
| router.push(`/find-account/result?email=${encodeURIComponent(data.email)}`); | ||
| } |
There was a problem hiding this comment.
Avoid exposing recovered email in URL query params.
Passing data.email in the URL at Line 33 leaks PII through browser history, referrer headers, and server/access logs. Route to the result page without the email in the query, and pass it via ephemeral client state (or a backend-issued opaque token) instead.
Suggested direction
- router.push(`/find-account/result?email=${encodeURIComponent(data.email)}`);
+ sessionStorage.setItem("foundEmail", data.email);
+ router.push("/find-account/result");And in the result page client, read foundEmail from sessionStorage with a safe fallback.
📝 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.
| findEmail( | |
| { name, phoneNumber: phone.replace(/-/g, "") }, | |
| { | |
| onSuccess: (data) => { | |
| router.push(`/find-account/result?email=${encodeURIComponent(data.email)}`); | |
| } | |
| findEmail( | |
| { name, phoneNumber: phone.replace(/-/g, "") }, | |
| { | |
| onSuccess: (data) => { | |
| sessionStorage.setItem("foundEmail", data.email); | |
| router.push("/find-account/result"); | |
| } |
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@src/app/`(public)/find-account/FindAccountPageClient.tsx around lines 29 -
34, The code in FindAccountPageClient is leaking PII by putting data.email into
router.push query; instead, before calling router.push in the findEmail
onSuccess handler, store the recovered email in ephemeral client state (e.g.,
sessionStorage under a key like "foundEmail" or request an opaque backend token)
and then navigate to the result page without including email in the URL (change
the router.push call in findEmail to route to "/find-account/result" with no
email param). Update the result page client to read the email from
sessionStorage (with a safe fallback if absent) or exchange the opaque token for
the email, and remove any encodeURIComponent(data.email) usage in
FindAccountPageClient.
| useEffect(() => { | ||
| const isSocialParam = searchParams.get("isSocial"); | ||
| const urlIsSocial = isSocialParam === "true"; | ||
| const urlIsSocialFalse = isSocialParam === "false"; | ||
|
|
||
| if (urlIsSocialFalse) { | ||
| router.replace("/"); | ||
| return; | ||
| } | ||
|
|
||
| const storeIsSocial = isLoggedIn && user && !user.nickname; | ||
| const shouldBeSocial = urlIsSocial || storeIsSocial; | ||
|
|
||
| if (shouldBeSocial) { | ||
| if (!isSocial) { | ||
| setIsSocial(true); | ||
| } | ||
|
|
||
| const newEmail = user?.email ?? searchParams.get("email"); | ||
| if (newEmail && newEmail !== email) { | ||
| setEmail(newEmail); | ||
| } | ||
|
|
||
| if (step === "email" || step === "password") { | ||
| router.replace(`/signup/profile?isSocial=true`); | ||
| } | ||
| } | ||
| }, [searchParams, user, isLoggedIn, isSocial, email, setIsSocial, setEmail, step, router]); |
There was a problem hiding this comment.
Reset social-signup state when social conditions are no longer met.
Current logic only turns isSocial on. If context keeps a stale isSocial=true, normal signup can be routed as social and skip email/password unexpectedly.
Proposed fix
if (shouldBeSocial) {
if (!isSocial) {
setIsSocial(true);
}
const newEmail = user?.email ?? searchParams.get("email");
if (newEmail && newEmail !== email) {
setEmail(newEmail);
}
if (step === "email" || step === "password") {
router.replace(`/signup/profile?isSocial=true`);
}
+ } else if (isSocial) {
+ setIsSocial(false);
+ setEmail("");
}📝 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.
| useEffect(() => { | |
| const isSocialParam = searchParams.get("isSocial"); | |
| const urlIsSocial = isSocialParam === "true"; | |
| const urlIsSocialFalse = isSocialParam === "false"; | |
| if (urlIsSocialFalse) { | |
| router.replace("/"); | |
| return; | |
| } | |
| const storeIsSocial = isLoggedIn && user && !user.nickname; | |
| const shouldBeSocial = urlIsSocial || storeIsSocial; | |
| if (shouldBeSocial) { | |
| if (!isSocial) { | |
| setIsSocial(true); | |
| } | |
| const newEmail = user?.email ?? searchParams.get("email"); | |
| if (newEmail && newEmail !== email) { | |
| setEmail(newEmail); | |
| } | |
| if (step === "email" || step === "password") { | |
| router.replace(`/signup/profile?isSocial=true`); | |
| } | |
| } | |
| }, [searchParams, user, isLoggedIn, isSocial, email, setIsSocial, setEmail, step, router]); | |
| useEffect(() => { | |
| const isSocialParam = searchParams.get("isSocial"); | |
| const urlIsSocial = isSocialParam === "true"; | |
| const urlIsSocialFalse = isSocialParam === "false"; | |
| if (urlIsSocialFalse) { | |
| router.replace("/"); | |
| return; | |
| } | |
| const storeIsSocial = isLoggedIn && user && !user.nickname; | |
| const shouldBeSocial = urlIsSocial || storeIsSocial; | |
| if (shouldBeSocial) { | |
| if (!isSocial) { | |
| setIsSocial(true); | |
| } | |
| const newEmail = user?.email ?? searchParams.get("email"); | |
| if (newEmail && newEmail !== email) { | |
| setEmail(newEmail); | |
| } | |
| if (step === "email" || step === "password") { | |
| router.replace(`/signup/profile?isSocial=true`); | |
| } | |
| } else if (isSocial) { | |
| setIsSocial(false); | |
| setEmail(""); | |
| } | |
| }, [searchParams, user, isLoggedIn, isSocial, email, setIsSocial, setEmail, step, router]); |
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@src/app/`(public)/signup/[step]/SignupStepPageClient.tsx around lines 22 -
49, The effect in SignupStepPageClient.tsx only sets isSocial true and never
resets it, causing stale social-signup state; update the useEffect (which reads
searchParams.get("isSocial"), computes urlIsSocial/urlIsSocialFalse,
storeIsSocial and shouldBeSocial) to explicitly setIsSocial(false) when
shouldBeSocial is false (and clear any social-derived email via setEmail if it
came from user?.email or searchParams.get("email") to avoid carrying a social
email into normal signup), and keep the existing router.replace logic for
urlIsSocialFalse and profile redirects when shouldBeSocial is true.
📌 개요 (Summary)
05.23 최신화
Summary by CodeRabbit
Style
SEO/Infrastructure