Skip to content

03/17 최신화 - #205

Merged
shinwokkang merged 111 commits into
mainfrom
dev
Mar 20, 2026
Merged

shinwokkang merged 111 commits into
mainfrom
dev

Conversation

@shinwokkang

@shinwokkang shinwokkang commented Mar 17, 2026 •

Copy link
Copy Markdown
Contributor

📌 개요 (Summary)

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

🛠️ 변경 사항 (Changes)

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

📸 스크린샷 (Screenshots)

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

✅ 체크리스트 (Checklist)

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

Summary by CodeRabbit

  • New Features

    • Account recovery flows: Find Account, result, and Password Reset pages.
    • Admin: news create/edit UI with image previews and uploads; admin story detail/navigation pages.
    • Notification dropdown with preview and optimistic read updates.
    • Home: new News banner/slider, Club, Recommendation, and Story sections including infinite story list.
    • Settings: Support page and email verification/update UI.
    • Follower management: delete follower action in UI.
  • Bug Fixes & Improvements

    • Improved image URL validation with consistent fallbacks.
    • New loading skeletons, minor UI text tweaks, and logout cache/refresh behavior.

nonoididnt and others added 30 commits March 8, 2026 14:28

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 13

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@src/app/`(admin)/admin/(app)/news/[id]/edit/page.tsx:
- Around line 20-21: The route param parsing currently does Number(params.id)
into newsId and can produce NaN; update the component/page to validate newsId
(e.g., ensure Number.isInteger(newsId) and newsId > 0) before rendering the edit
form or calling updateAdminNewsWithImages; if validation fails, redirect to a
safe route or render a not-found/invalid-id placeholder and prevent the load
effect and onSubmit from running. Locate references to params.id/newsId and the
call to updateAdminNewsWithImages(newsId, ...) and gate rendering, data loading,
and submit handling on this positive-integer check so an invalid route never
triggers the update logic.
- Around line 167-173: The submit currently builds existingImageUrls and
imageFiles but the API helper used by updateAdminNewsWithImages discards
retained URLs when new files are uploaded; fix the helper logic in
updateAdminNewsWithImages (or the upload/merge helper it calls) to
concatenate/merge retained existingImageUrls with the newly uploaded file URLs
(preserving intended order and avoiding duplicates) instead of choosing one
branch or the other; apply the same merge change to the other submission path
that handles the second block so both uses of existingImageUrls + imageFiles
preserve existing gallery images when new files are added.
- Around line 76-88: The cleanup useEffect currently depends on repPreview and
extraImages so its cleanup runs on every update and may revoke newly-created
blob URLs; change this to run only on unmount by using a no-deps useEffect and
track the latest blob URLs with refs: create refs (e.g., repPreviewRef and
extraImagesRef) updated whenever repPreview or extraImages change (e.g., in
separate effects or in the setters), and in the single cleanup effect
(useEffect(() => { return () => { ... } }, [])) revoke URLs from
repPreviewRef.current and extraImagesRef.current (checking for "blob:" and
image.type==="new") so revocation happens only on unmount and not before new
previews are rendered; update references to repPreview and extraImages in
functions like onPickExtraImages to also update the refs.

In `@src/app/`(admin)/admin/(app)/stories/[id]/page.tsx:
- Around line 33-55: The effect's loadStory can suffer from race conditions
where out-of-order fetches override state; modify the useEffect/loadStory to
cancel or ignore stale responses by using an AbortController (pass its signal to
fetchAdminBookStoryDetail) or a local requestId boolean flag: create an
AbortController or increment a requestId before calling
fetchAdminBookStoryDetail(id), check signal or requestId before calling
setStory/setIsError/setIsLoading, and call controller.abort() or mark the
request stale in the cleanup function so only the latest request updates state
(update references: useEffect, loadStory, fetchAdminBookStoryDetail, setStory,
setIsError, setIsLoading, id, router).
- Around line 28-29: The admin page is calling user-facing mutation hooks
(useToggleStoryLikeMutation -> storyService.toggleLikeStory and
useToggleFollowMutation -> memberService.followMember/unfollowMember) which will
perform real social actions; change the page to use admin-safe behavior by
replacing those hooks with either read-only hooks or no-op/confirmation wrappers
(e.g., a dedicated adminToggleLike/adminFollow service or a disabled mutate that
only simulates actions) or gate the mutate calls with an isAdmin check so they
never call storyService.toggleLikeStory or
memberService.followMember/unfollowMember from this admin UI; update the
handlers in the component where useToggleStoryLikeMutation and
useToggleFollowMutation are used (also at the other occurrences you noted) to
call the admin-safe functions instead.

In `@src/app/`(admin)/admin/(app)/stories/page.tsx:
- Around line 32-35: The current search only filters the loaded page because
loadStories (inside useEffect) calls fetchAdminBookStories(page - 1, pageSize)
without the search keyword and handleSearch merely logs and setPage(1); update
handleSearch to store the search term (e.g., setKeyword(value)) and call
setPage(1), then change loadStories to call fetchAdminBookStories(page - 1,
pageSize, keyword) and update setStories and setTotalPages from the fetched
filtered result; ensure keyword is included in the useEffect dependency array so
searches trigger a reload, and if fetchAdminBookStories lacks a keyword
parameter update that function signature/backend call to accept and apply the
global keyword filter.
- Around line 32-58: The effect can apply stale responses to state when rapid
pagination triggers overlapping requests; modify the useEffect + loadStories
logic to ignore stale responses by capturing the current page/request id and
only calling setStories/setTotalPages if the request is still current (or use an
AbortController to cancel the previous fetch). Concretely: inside useEffect
create a local token (e.g., const current = Symbol() or incrementing
requestIdRef) or an AbortController, pass it into fetchAdminBookStories (if it
supports signal) or check the token after await, and in the cleanup return
handler mark the token as stale (or call controller.abort()) so that results
from earlier requests do not call setStories or setTotalPages; update references
to useEffect, loadStories, fetchAdminBookStories, setStories, setTotalPages, and
page accordingly.

In `@src/components/base-ui/Admin/stories/bookstory_detail.tsx`:
- Around line 79-93: The outside-click logic uses a single menuRef (menuRef) for
both desktop and mobile menus, causing clicks inside one menu to be mistaken as
outside clicks; create two refs (e.g., desktopMenuRef and mobileMenuRef), attach
them to the respective menu containers, and update the useEffect handler
(handleClickOutside) to check both refs (if neither contains e.target then call
setMenuOpen(false)); ensure the document event listener registration and cleanup
remain in the same useEffect and update all other occurrences where menuRef is
shared (the other menu refs referenced in this file) to use the separate refs.
- Around line 95-105: The report flow currently only calls reportMember from
handleReportSubmit with reportedMemberNickname, reportType, and content, which
loses the story/comment target; update handleReportSubmit to include the
reported entity id (e.g., storyId or commentId) based on the incoming type
mapping and pass that to reportMember, and then update the mutation/service/type
signatures (useReportMemberMutation, memberService.reportMember, and the
ReportMemberRequest type) to accept optional reportedStoryId and
reportedCommentId fields so reports from the story detail screen can be
attributed to the correct story or comment rather than just the member.
- Around line 221-237: Replace the clickable <div>s used for like/share with
real <button type="button"> elements (the blocks that call onLikeClick and
handleShare and reference likedByMe and likeCount) so they are
keyboard-focusable and activatable by Enter/Space; preserve existing classes and
stopPropagation logic, move e.stopPropagation() into the button onClick handler,
add accessible attributes such as aria-pressed={likedByMe} and a descriptive
aria-label (e.g., "좋아요" with count) for the like button and aria-label="공유하기"
for the share button, and apply the same change to the other similar controls
around lines 283-306 in this file.
- Around line 359-370: The desktop menu item is mislabeled: it shows "삭제하기" but
calls handleShare and uses the share icon ("/gray_share.svg"), so fix by making
the UI and behavior consistent—either change the label to "공유하기" (and keep
handleShare and the share icon) or wire it to the delete flow by replacing
handleShare with the appropriate delete handler (e.g., handleDelete), swap the
icon to a delete icon, and adjust the alt text; update the button text, onClick
handler, Image src and alt accordingly to match the chosen action.

In `@src/components/base-ui/Admin/stories/story_navigation.tsx`:
- Around line 47-49: The mobile and desktop wrappers in story_navigation.tsx
currently mount {children} twice (mobile wrapper at className "relative t:hidden
w-full" and the desktop wrapper around lines 86-87), causing duplicate
BookstoryDetail instances; change this to mount children only once by using a
single render path: detect viewport (e.g., a small useMedia or isDesktop hook)
and conditionally render either the mobile wrapper or the desktop wrapper around
the same children, or lift the children out into a single container and apply
responsive classes to that container so BookstoryDetail (and its
effects/listeners) is instantiated only once.

In `@src/lib/api/admin/stories.ts`:
- Around line 35-41: The current fetch handling returns data.result whenever
HTTP status is OK, but it ignores the API envelope fields (isSuccess, code,
message); update the response handling after parsing AdminBookStoryListResponse
to check if body.isSuccess is true before returning result, and if not throw an
Error (include body.code and body.message in the error text); apply the same
envelope check/failure throw to the other similar helper block around lines
83-89 that also returns result.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 6ecd287a-bebd-43d0-8727-443ca2a735e5

📥 Commits

Reviewing files that changed from the base of the PR and between 45679a3 and 4467691.

📒 Files selected for processing (7)
  • src/app/(admin)/admin/(app)/news/[id]/edit/page.tsx
  • src/app/(admin)/admin/(app)/stories/[id]/page.tsx
  • src/app/(admin)/admin/(app)/stories/page.tsx
  • src/components/base-ui/Admin/stories/bookstory_detail.tsx
  • src/components/base-ui/Admin/stories/story_navigation.tsx
  • src/lib/api/admin/endpoints/stories.ts
  • src/lib/api/admin/stories.ts

Comment on lines +20 to +21
const newsId = Number(params.id);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Reject invalid route params before rendering the edit form.

A mistyped URL like /admin/news/foo/edit makes newsId NaN. The load effect then bails out, but the blank form still renders and onSubmit later calls updateAdminNewsWithImages(newsId, ...) with that invalid id. Redirect or block the form unless the param is a positive integer.

Also applies to: 40-41, 178-178

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

In `@src/app/`(admin)/admin/(app)/news/[id]/edit/page.tsx around lines 20 - 21,
The route param parsing currently does Number(params.id) into newsId and can
produce NaN; update the component/page to validate newsId (e.g., ensure
Number.isInteger(newsId) and newsId > 0) before rendering the edit form or
calling updateAdminNewsWithImages; if validation fails, redirect to a safe route
or render a not-found/invalid-id placeholder and prevent the load effect and
onSubmit from running. Locate references to params.id/newsId and the call to
updateAdminNewsWithImages(newsId, ...) and gate rendering, data loading, and
submit handling on this positive-integer check so an invalid route never
triggers the update logic.

Comment on lines +76 to +88
useEffect(() => {
return () => {
if (repPreview && repPreview.startsWith("blob:")) {
URL.revokeObjectURL(repPreview);
}

extraImages.forEach((image) => {
if (image.type === "new") {
URL.revokeObjectURL(image.previewUrl);
}
});
};
}, [repPreview, extraImages]);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

find . -type f -name "page.tsx" | grep -E "news/\[id\]/edit"

Repository: checkmo2025/FE

Length of output: 111


🏁 Script executed:

cat -n ./src/app/(admin)/admin/(app)/news/[id]/edit/page.tsx | head -100

Repository: checkmo2025/FE

Length of output: 217


🏁 Script executed:

cat -n "./src/app/(admin)/admin/(app)/news/[id]/edit/page.tsx" | head -100

Repository: checkmo2025/FE

Length of output: 3688


🏁 Script executed:

cat -n "./src/app/(admin)/admin/(app)/news/[id]/edit/page.tsx" | sed -n '90,150p'

Repository: checkmo2025/FE

Length of output: 2117


🏁 Script executed:

cat -n "./src/app/(admin)/admin/(app)/news/[id]/edit/page.tsx" | sed -n '1,20p'

Repository: checkmo2025/FE

Length of output: 783


🏁 Script executed:

cat -n "./src/app/(admin)/admin/(app)/news/[id]/edit/page.tsx" | sed -n '200,250p'

Repository: checkmo2025/FE

Length of output: 1616


Don't revoke blob URLs on every state change—run cleanup only on unmount.

The cleanup in this useEffect runs before each repPreview/extraImages update due to the dependency array, not only on component unmount. When adding extra images, onPickExtraImages creates new blob URLs, but the cleanup function (using the old state from closure) revokes them before the new render happens, causing previews to break.

The fix is to use refs to capture the final blob URL values and run cleanup only on unmount:

Suggested implementation
-import React, { useEffect, useState } from "react";
+import React, { useEffect, useRef, useState } from "react";
@@
   const [extraImages, setExtraImages] = useState<ExtraImageItem[]>([]);
+  const repPreviewRef = useRef<string | null>(null);
+  const extraImagesRef = useRef<ExtraImageItem[]>([]);
+
+  useEffect(() => {
+    repPreviewRef.current = repPreview;
+    extraImagesRef.current = extraImages;
+  }, [repPreview, extraImages]);
@@
   useEffect(() => {
     return () => {
-      if (repPreview && repPreview.startsWith("blob:")) {
-        URL.revokeObjectURL(repPreview);
+      if (repPreviewRef.current?.startsWith("blob:")) {
+        URL.revokeObjectURL(repPreviewRef.current);
       }
 
-      extraImages.forEach((image) => {
+      extraImagesRef.current.forEach((image) => {
         if (image.type === "new") {
           URL.revokeObjectURL(image.previewUrl);
         }
       });
     };
-  }, [repPreview, extraImages]);
+  }, []);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/app/`(admin)/admin/(app)/news/[id]/edit/page.tsx around lines 76 - 88,
The cleanup useEffect currently depends on repPreview and extraImages so its
cleanup runs on every update and may revoke newly-created blob URLs; change this
to run only on unmount by using a no-deps useEffect and track the latest blob
URLs with refs: create refs (e.g., repPreviewRef and extraImagesRef) updated
whenever repPreview or extraImages change (e.g., in separate effects or in the
setters), and in the single cleanup effect (useEffect(() => { return () => { ...
} }, [])) revoke URLs from repPreviewRef.current and extraImagesRef.current
(checking for "blob:" and image.type==="new") so revocation happens only on
unmount and not before new previews are rendered; update references to
repPreview and extraImages in functions like onPickExtraImages to also update
the refs.

Comment on lines +167 to +173
const existingImageUrls = extraImages
.filter((image): image is { type: "existing"; url: string } => image.type === "existing")
.map((image) => image.url);

const imageFiles = extraImages
.filter((image): image is { type: "new"; file: File; previewUrl: string } => image.type === "new")
.map((image) => image.file);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🔴 Critical

Adding new extra images currently drops retained existing ones.

This submit path assumes updateAdminNewsWithImages() merges existingImageUrls and imageFiles, but the helper in src/lib/api/admin/news.ts:166-207 chooses one or the other. If an admin keeps existing gallery images and adds one new file, the saved payload contains only the new upload URLs.

🧩 Suggested fix in src/lib/api/admin/news.ts
-  const imageUrls =
-    imageFiles.length > 0
-      ? await Promise.all(imageFiles.map((f) => uploadImage(uploadType, f)))
-      : existingImageUrls;
+  const uploadedImageUrls = await Promise.all(
+    imageFiles.map((f) => uploadImage(uploadType, f))
+  );
+  const imageUrls = [...existingImageUrls, ...uploadedImageUrls];

Also applies to: 178-190

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

In `@src/app/`(admin)/admin/(app)/news/[id]/edit/page.tsx around lines 167 - 173,
The submit currently builds existingImageUrls and imageFiles but the API helper
used by updateAdminNewsWithImages discards retained URLs when new files are
uploaded; fix the helper logic in updateAdminNewsWithImages (or the upload/merge
helper it calls) to concatenate/merge retained existingImageUrls with the newly
uploaded file URLs (preserving intended order and avoiding duplicates) instead
of choosing one branch or the other; apply the same merge change to the other
submission path that handles the second block so both uses of existingImageUrls
+ imageFiles preserve existing gallery images when new files are added.

Comment on lines +28 to +29
const { mutate: toggleLike } = useToggleStoryLikeMutation();
const { mutate: toggleFollow } = useToggleFollowMutation();

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

This admin page is wired to mutate public likes and follows.

useToggleStoryLikeMutation here ultimately calls storyService.toggleLikeStory() (src/hooks/mutations/useStoryMutations.ts:151-175, src/services/storyService.ts:103-108), and useToggleFollowMutation calls memberService.followMember()/unfollowMember() (src/hooks/mutations/useMemberMutations.ts:154-185, src/services/memberService.ts:52-67). That means interacting with this admin screen can create real user-facing social actions under the admin account.

Also applies to: 57-80, 160-164

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

In `@src/app/`(admin)/admin/(app)/stories/[id]/page.tsx around lines 28 - 29, The
admin page is calling user-facing mutation hooks (useToggleStoryLikeMutation ->
storyService.toggleLikeStory and useToggleFollowMutation ->
memberService.followMember/unfollowMember) which will perform real social
actions; change the page to use admin-safe behavior by replacing those hooks
with either read-only hooks or no-op/confirmation wrappers (e.g., a dedicated
adminToggleLike/adminFollow service or a disabled mutate that only simulates
actions) or gate the mutate calls with an isAdmin check so they never call
storyService.toggleLikeStory or memberService.followMember/unfollowMember from
this admin UI; update the handlers in the component where
useToggleStoryLikeMutation and useToggleFollowMutation are used (also at the
other occurrences you noted) to call the admin-safe functions instead.

Comment on lines +33 to +55
useEffect(() => {
if (Number.isNaN(id)) {
router.push("/admin/stories");
return;
}

const loadStory = async () => {
try {
setIsLoading(true);
setIsError(false);

const result = await fetchAdminBookStoryDetail(id);
setStory(result);
} catch (error) {
console.error("책 이야기 상세 조회 실패:", error);
setIsError(true);
} finally {
setIsLoading(false);
}
};

loadStory();
}, [id, router]);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Guard against out-of-order detail fetches.

Prev/next navigation can start a new request before the previous one finishes. Without cleanup, the older response can still win the race and replace the currently selected story or flip isError back on.

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

In `@src/app/`(admin)/admin/(app)/stories/[id]/page.tsx around lines 33 - 55, The
effect's loadStory can suffer from race conditions where out-of-order fetches
override state; modify the useEffect/loadStory to cancel or ignore stale
responses by using an AbortController (pass its signal to
fetchAdminBookStoryDetail) or a local requestId boolean flag: create an
AbortController or increment a requestId before calling
fetchAdminBookStoryDetail(id), check signal or requestId before calling
setStory/setIsError/setIsLoading, and call controller.abort() or mark the
request stale in the cleanup function so only the latest request updates state
(update references: useEffect, loadStory, fetchAdminBookStoryDetail, setStory,
setIsError, setIsLoading, id, router).

Comment on lines +95 to +105
const handleReportSubmit = (type: string, content: string) => {
let mappedType: ReportType = "GENERAL";
if (type === "책 이야기") mappedType = "BOOK_STORY";
if (type === "책이야기(댓글)") mappedType = "COMMENT";
if (type === "책모임 내부") mappedType = "CLUB_MEETING";

reportMember({
reportedMemberNickname: authorNickname,
reportType: mappedType,
content,
});

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

This flow can't identify which story or comment was reported.

useReportMemberMutation sends only reportedMemberNickname, reportType, and content (src/hooks/mutations/useMemberMutations.ts:322-335, src/services/memberService.ts:76-84, src/types/member.ts:45-51). On a story detail screen, that collapses content reports into a member-level report target.

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

In `@src/components/base-ui/Admin/stories/bookstory_detail.tsx` around lines 95 -
105, The report flow currently only calls reportMember from handleReportSubmit
with reportedMemberNickname, reportType, and content, which loses the
story/comment target; update handleReportSubmit to include the reported entity
id (e.g., storyId or commentId) based on the incoming type mapping and pass that
to reportMember, and then update the mutation/service/type signatures
(useReportMemberMutation, memberService.reportMember, and the
ReportMemberRequest type) to accept optional reportedStoryId and
reportedCommentId fields so reports from the story detail screen can be
attributed to the correct story or comment rather than just the member.

Comment on lines +221 to +237
<div
onClick={(e) => {
e.stopPropagation();
onLikeClick?.(e);
}}
className="flex items-center gap-2 cursor-pointer hover:bg-gray-100 p-1 rounded-full transition-colors"
>
<Image src={heartIcon} alt="heart" width={20} height={20} />
<span className={`body_1_2 ${likedByMe ? 'text-primary-2' : 'text-Gray-5'}`}>좋아요 {likeCount}</span>
</div>
<div
className="flex items-center gap-2 cursor-pointer"
onClick={handleShare}
>
<Image src="/share.svg" alt="share" width={20} height={20} />
<span className="body_1_2 text-Gray-5">공유하기</span>
</div>

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Use real buttons for the like/share actions.

These controls are rendered as <div onClick>, so keyboard users can't tab to them or activate them with Enter/Space. That blocks the actions for non-pointer users.

Also applies to: 283-306

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

In `@src/components/base-ui/Admin/stories/bookstory_detail.tsx` around lines 221 -
237, Replace the clickable <div>s used for like/share with real <button
type="button"> elements (the blocks that call onLikeClick and handleShare and
reference likedByMe and likeCount) so they are keyboard-focusable and
activatable by Enter/Space; preserve existing classes and stopPropagation logic,
move e.stopPropagation() into the button onClick handler, add accessible
attributes such as aria-pressed={likedByMe} and a descriptive aria-label (e.g.,
"좋아요" with count) for the like button and aria-label="공유하기" for the share
button, and apply the same change to the other similar controls around lines
283-306 in this file.

Comment on lines +359 to +370
<button
type="button"
onClick={handleShare}
className="flex w-full items-center gap-2 px-4 py-3 body_1_2 text-Gray-4 hover:text-Gray-7 cursor-pointer"
>
<Image
src="/gray_share.svg"
alt="공유"
width={20}
height={20}
/>
삭제하기

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

The desktop menu label doesn't match the handler.

This item says "삭제하기" but still calls handleShare and uses the share icon, so it copies the URL instead of deleting anything.

✏️ Minimal fix if this is meant to stay as the share action
-                  삭제하기
+                  공유하기
📝 Committable suggestion

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

Suggested change
<button
type="button"
onClick={handleShare}
className="flex w-full items-center gap-2 px-4 py-3 body_1_2 text-Gray-4 hover:text-Gray-7 cursor-pointer"
>
<Image
src="/gray_share.svg"
alt="공유"
width={20}
height={20}
/>
삭제하기
<button
type="button"
onClick={handleShare}
className="flex w-full items-center gap-2 px-4 py-3 body_1_2 text-Gray-4 hover:text-Gray-7 cursor-pointer"
>
<Image
src="/gray_share.svg"
alt="공유"
width={20}
height={20}
/>
공유하기
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/components/base-ui/Admin/stories/bookstory_detail.tsx` around lines 359 -
370, The desktop menu item is mislabeled: it shows "삭제하기" but calls handleShare
and uses the share icon ("/gray_share.svg"), so fix by making the UI and
behavior consistent—either change the label to "공유하기" (and keep handleShare and
the share icon) or wire it to the delete flow by replacing handleShare with the
appropriate delete handler (e.g., handleDelete), swap the icon to a delete icon,
and adjust the alt text; update the button text, onClick handler, Image src and
alt accordingly to match the chosen action.

Comment on lines +47 to +49
{/* 모바일: 컨텐츠 + 오버레이 화살표 */}
<div className="relative t:hidden w-full">
{children}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Don't mount children twice for responsive layout.

The mobile wrapper and the desktop wrapper both render children and only hide one with CSS. Because BookstoryDetail has its own state/effects, this creates two live instances, duplicate listeners/modals, and extra render work.

Also applies to: 86-87

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

In `@src/components/base-ui/Admin/stories/story_navigation.tsx` around lines 47 -
49, The mobile and desktop wrappers in story_navigation.tsx currently mount
{children} twice (mobile wrapper at className "relative t:hidden w-full" and the
desktop wrapper around lines 86-87), causing duplicate BookstoryDetail
instances; change this to mount children only once by using a single render
path: detect viewport (e.g., a small useMedia or isDesktop hook) and
conditionally render either the mobile wrapper or the desktop wrapper around the
same children, or lift the children out into a single container and apply
responsive classes to that container so BookstoryDetail (and its
effects/listeners) is instantiated only once.

Comment on lines +35 to +41
if (!res.ok) {
const text = await res.text().catch(() => "");
throw new Error(`책 이야기 목록 조회 실패: ${res.status} ${text}`);
}

const data: AdminBookStoryListResponse = await res.json();
return data.result;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Respect the API envelope before returning result.

Both helpers declare { isSuccess, code, message, result } but only check res.ok. If the backend returns 200 with isSuccess: false, callers will still treat a business failure as valid data.

🛠️ Minimal fix
   const data: AdminBookStoryListResponse = await res.json();
+  if (!data.isSuccess) {
+    throw new Error(`책 이야기 목록 조회 실패: ${data.code} ${data.message}`);
+  }
   return data.result;
@@
   const data: AdminBookStoryDetailResponse = await res.json();
+  if (!data.isSuccess) {
+    throw new Error(`책 이야기 상세 조회 실패: ${data.code} ${data.message}`);
+  }
   return data.result;

Also applies to: 83-89

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

In `@src/lib/api/admin/stories.ts` around lines 35 - 41, The current fetch
handling returns data.result whenever HTTP status is OK, but it ignores the API
envelope fields (isSuccess, code, message); update the response handling after
parsing AdminBookStoryListResponse to check if body.isSuccess is true before
returning result, and if not throw an Error (include body.code and body.message
in the error text); apply the same envelope check/failure throw to the other
similar helper block around lines 83-89 that also returns result.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@src/app/`(main)/profile/[nickname]/page.tsx:
- Around line 20-22: The code double-decodes the route param: inside
OtherUserProfileContent you're calling React.use(params) to get encodedNickname
and then running decodeURIComponent on it to produce nickname; remove the
unnecessary decodeURIComponent call and just use the param value directly (e.g.,
assign nickname = encodedNickname || "") so the component uses the
already-decoded route param and avoids errors with literal '%' characters.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 96476c75-9ab3-4329-87d0-aa53f7d4c30e

📥 Commits

Reviewing files that changed from the base of the PR and between 4467691 and b10dbf0.

📒 Files selected for processing (9)
  • src/app/(main)/profile/[nickname]/page.tsx
  • src/components/base-ui/MyPage/Lists/MyBookStoryList.tsx
  • src/components/base-ui/Profile/Lists/BookStoryList.tsx
  • src/components/base-ui/Profile/Lists/MeetingList.tsx
  • src/components/base-ui/Profile/OtherUser/OtherUserProfileTabs.tsx
  • src/components/base-ui/Profile/OtherUser/ProfileUserInfo.tsx
  • src/hooks/queries/useClubQueries.ts
  • src/lib/api/endpoints/Clubs.ts
  • src/services/clubService.ts
✅ Files skipped from review due to trivial changes (1)
  • src/lib/api/endpoints/Clubs.ts
🚧 Files skipped from review as they are similar to previous changes (4)
  • src/components/base-ui/MyPage/Lists/MyBookStoryList.tsx
  • src/components/base-ui/Profile/OtherUser/ProfileUserInfo.tsx
  • src/components/base-ui/Profile/Lists/BookStoryList.tsx
  • src/components/base-ui/Profile/OtherUser/OtherUserProfileTabs.tsx

Comment on lines +20 to +22
function OtherUserProfileContent({ params }: PageProps) {
const { nickname: encodedNickname } = React.use(params);
const nickname = encodedNickname ? decodeURIComponent(encodedNickname) : "";

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🌐 Web query:

In Next.js App Router (v16), are dynamic route params already URL-decoded in page components / useParams, or should client pages call decodeURIComponent manually on params.nickname?

💡 Result:

In Next.js App Router (v16), dynamic route params like params.nickname in page components and useParams in client components are already URL-decoded. You do not need to manually call decodeURIComponent on them. Official Next.js documentation shows examples where URLs with path segments containing hyphens (which require URL encoding as %2D if raw) are provided to params/useParams as the decoded human-readable strings. For example: - URL /shop/shoes/nike-air-max-97 → params/useParams = { tag: 'shoes', item: 'nike-air-max-97' } - URL /shop/1 → { slug: '1' } This behavior is consistent across server params props and client useParams hook. Next.js handles the decoding internally when matching routes and populating params, following standard URL path segment parsing where each segment is decoded. While some GitHub issues (#47546) noted early App Router bugs with specific characters like '@' appearing encoded (%40), these have been fixed, and current docs/examples confirm decoded values. For non-ASCII like Korean characters, community examples recommend manual decoding only if storing raw encoded forms, but standard usage matches decoded strings. No official docs or recent sources (as of 2026-03-19) require manual decoding for typical nickname params.

Citations:


Remove the unnecessary decodeURIComponent call on the route param.

Next.js App Router already URL-decodes dynamic route params before passing them to page components. Calling decodeURIComponent again will fail if the nickname contains a literal % character and creates unnecessary coupling to framework internals.

Proposed fix
-  const nickname = encodedNickname ? decodeURIComponent(encodedNickname) : "";
+  const nickname = encodedNickname ?? "";
📝 Committable suggestion

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

Suggested change
function OtherUserProfileContent({ params }: PageProps) {
const { nickname: encodedNickname } = React.use(params);
const nickname = encodedNickname ? decodeURIComponent(encodedNickname) : "";
function OtherUserProfileContent({ params }: PageProps) {
const { nickname: encodedNickname } = React.use(params);
const nickname = encodedNickname ?? "";
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/app/`(main)/profile/[nickname]/page.tsx around lines 20 - 22, The code
double-decodes the route param: inside OtherUserProfileContent you're calling
React.use(params) to get encodedNickname and then running decodeURIComponent on
it to produce nickname; remove the unnecessary decodeURIComponent call and just
use the param value directly (e.g., assign nickname = encodedNickname || "") so
the component uses the already-decoded route param and avoids errors with
literal '%' characters.

@shinwokkang
shinwokkang merged commit 498de60 into main Mar 20, 2026
4 checks passed
@coderabbitai coderabbitai Bot mentioned this pull request Apr 6, 2026
8 tasks
@coderabbitai coderabbitai Bot mentioned this pull request Apr 13, 2026
8 tasks
@coderabbitai coderabbitai Bot mentioned this pull request May 22, 2026

This branch was successfully deployed

2 active deployments
Preview – check-mo — de3888db Deployed Mar 19, 2026 by vercel[bot]
Preview – checkmo — de3888db Deployed Mar 19, 2026 by vercel[bot]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants