QA: 나머지 qa 반영#188
Conversation
Walkthrough여러 컴포넌트의 레이아웃/간격을 조정하고, 드롭다운에 애니메이션 기반 열림/닫힘 제어를 추가했습니다. 캡슐 생성 성공 후 2초 로딩 화면을 도입했고, 특정 모달의 오버레이 클릭 닫기를 제거했습니다. 타입 및 Chip API 일부가 변경되었습니다. Changes
Sequence Diagram(s)sequenceDiagram
autonumber
actor U as 사용자
participant P as CreateCapsule Page
participant M as useCreateCapsule (mutation)
participant L as CreateCapsuleLoading
U->>P: 폼 제출
P->>M: mutate()
M-->>P: isPending=true
P->>P: showLoading=true (로딩 화면 렌더)
alt mutation 성공
M-->>P: isPending=false
P->>P: 2초 타이머 시작
P-->>P: 2초 후 showLoading=false
P->>P: step="complete"
end
sequenceDiagram
autonumber
actor U as 사용자
participant D as Dropdown
participant C as DropdownContent
U->>D: 토글 클릭
alt 닫히는 경우 (열려있음)
D->>D: isClosing=true, isAnimating=true
D->>C: slideOut (closing 클래스 적용)
D->>D: 150ms 후 open=false, isClosing=false, isAnimating=false
else 열리는 경우 (닫혀있음)
D->>D: open=true, isAnimating=true, isClosing=false
D->>C: slideIn
D->>D: 애니메이션 후 isAnimating=false
end
rect rgba(0,0,0,0.03)
note over D,C: isAnimating/isClosing 동안 상호작용 제한, 외부 클릭은 open일 때만 처리
end
Estimated code review effort🎯 4 (Complex) | ⏱️ ~60 minutes Suggested reviewers
Tip 🔌 Remote MCP (Model Context Protocol) integration is now available!Pro plan users can now connect to remote MCP servers from the Integrations page. Connect with popular remote MCPs such as Notion and Linear to add more context to your reviews and chats. ✨ Finishing Touches
🧪 Generate unit tests
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. 🪧 TipsChatThere are 3 ways to chat with CodeRabbit:
SupportNeed help? Create a ticket on our support page for assistance with any issues or questions. CodeRabbit Commands (Invoked using PR/Issue comments)Type Other keywords and placeholders
Status, Documentation and Community
|
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
|
This pull request (commit
|
🚀 Storybook 배포📖 Storybook: https://683d91ab23651aa0b399e435-wylaapvfjf.chromatic.com/ |
There was a problem hiding this comment.
Actionable comments posted: 2
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (3)
shared/ui/popup/popup-cancel-creation/index.tsx (1)
27-29: 버튼 레이블 오탈자 추정: '남기' → '계속 만들기' 또는 '남기기'사용자 노출 텍스트로 보아 '남기'는 비문에 가깝습니다. 대화형 맥락(“만들기를 그만할까요?”)상 반대 액션은 “계속 만들기”가 자연스러워 보입니다. 디자인 가이드에 따라 ‘남기기’가 의도였다면 그쪽으로 정정해 주세요.
다음과 같이 수정 제안드립니다:
- 남기 + 계속 만들기원문 텍스트 표준을 유지하려면 i18n 키로 분리해 관리하는 것도 추천드립니다.
shared/ui/dropdown/index.tsx (2)
118-127: Trigger 버튼 기본 type 누락 → 폼 내 예기치 않은 submit 유발 가능button의 기본 type은 "submit"입니다. 폼 내부에서 드롭다운 트리거 클릭 시 폼 제출이 발생할 수 있어요. 또한 ARIA 속성으로 상태를 노출하면 접근성이 좋아집니다.
- <button + <button + type="button" onClick={(e) => { e.stopPropagation(); handleToggleOpen(); onClick?.(); }} className={cn(styles.triggerBtnStyle, className)} + aria-haspopup="menu" + aria-expanded={open} >
172-188: Item 버튼 기본 type 누락 → 폼 내 submit 방지 필요DropdownItem도 동일하게 기본 type 지정이 필요합니다. props로 type을 넘기면 override 가능하도록 스프레드 뒤쪽이 아닌 앞쪽에 "button"을 두는 현재 제안이 안전합니다.
<button - {...props} + type="button" + {...props} onClick={(e) => { e.stopPropagation(); handleToggleClose(); onClick?.(); }} className={cn(styles.dropdownItem, className)} role="menuitem" aria-label={label} >
🧹 Nitpick comments (26)
app/(sub)/create-capsule/_components/steps/intro-step/index.tsx (2)
19-31: 공백 전용 입력 UX 정합성: 버튼 비활성화와 글자수 카운트를 동일한 기준(trim)으로 통일현재 버튼
disabled는trim()기준이지만 글자수 카운트는 원본 길이를 사용하고 있어, 공백만 입력 시 “카운트는 증가하지만 버튼은 여전히 비활성화”인 불일치가 생깁니다. 동일한 트림 기준으로 맞추면 혼란을 줄일 수 있습니다. 렌더마다trim()호출을 줄이도록 파생값으로 계산해 재사용하면 가독성도 좋아집니다.const { field: titleField } = useController({ name: "title", control, }); const { field: subtitleField } = useController({ name: "subtitle", control, }); + // 공백 제거 기준을 한 곳에서 재사용 + const trimmedTitle = (typeof titleField.value === "string" ? titleField.value : "").trim(); + const handleClickNext = () => { handleNextStep("date"); }; @@ - <span className={styles.charCountTitle}>{titleField.value?.length || 0}/15</span> + <span className={styles.charCountTitle}>{trimmedTitle.length}/15</span> @@ - <Button variant="primary" text="다음" onClick={handleClickNext} disabled={!titleField.value?.trim()} /> + <Button variant="primary" text="다음" onClick={handleClickNext} disabled={trimmedTitle.length === 0} />참고: 위의 첫 번째 코멘트처럼
defaultValue: ""를 주면trim()호출 시 런타임 타입 오류 가능성도 사실상 제거됩니다.Also applies to: 63-64, 78-79
17-27: useController의 defaultValue 생략 가능 & 제목 검증 추가 권장
app/(sub)/create-capsule/page.tsx에서 이미 useForm({ defaultValues: { title: "", subtitle: "", … } })로 기본값을 지정하고 있어,useController에defaultValue를 별도 설정할 필요가 없습니다.
다만,handleNextStep등 프로그램적으로 이동할 때도 빈 문자열이 넘어가지 않도록titleField에 검증 로직을 추가하는 것을 권장드립니다. (react-hook-form.com)• 파일: app/(sub)/create-capsule/_components/steps/intro-step/index.tsx
• 위치:useController호출 부분옵셔널 제안:
const { field: titleField } = useController({ name: "title", control, - // defaultValue: "" // useForm defaultValues로 이미 설정됨 + rules: { + validate: (v) => + (typeof v === "string" && v.trim().length > 0) || + "제목은 공백일 수 없어요.", + }, });app/(main)/my-capsule/_components/card-container/card-container.css.ts (1)
6-7: 간격 값 하드코딩 대신 토큰/공통 상수로 치환 권장 (세 파일 모두 동일 패턴).세로/가로 간격을 명시적으로 분리해 둔 점은 좋습니다. 다만 "0.8rem / 2.4rem" 상수를 각 파일에서 반복 정의하고 있어 유지보수 시 드리프트 가능성이 있습니다. 공통 토큰(또는 상수)로 추출해 재사용해주세요.
적용 예시(현재 파일 내 상수 도입):
// 파일 상단에 추가 const COLUMN_GAP_SM = "0.8rem"; const ROW_GAP_SM = "2.4rem"; const COLUMN_GAP_LG = "1.2rem"; const ROW_GAP_LG = "3.2rem";해당 라인 변경 diff:
- gap: "0.8rem", - rowGap: "2.4rem", + gap: COLUMN_GAP_SM, + rowGap: ROW_GAP_SM,또는 디자인 토큰이 있다면(예: theme.space) 그 값으로 교체하는 것을 추천합니다.
app/(main)/explore/_components/card-container/card-container.css.ts (1)
5-6: 중복 숫자 제거로 유지보수성 향상 제안.my-capsule, explore, search 세 곳 모두 동일한 간격 스케일(0.8/2.4/1.2/3.2rem)을 사용합니다. 공통 스타일(예: baseCardGrid)로 추출해 style([base, overrides]) 형태로 합성하면 변경 범위를 한 곳으로 한정할 수 있습니다.
라인 치환 예시:
- gap: "0.8rem", - rowGap: "2.4rem", + gap: COLUMN_GAP_SM, + rowGap: ROW_GAP_SM,공통화 아이디어(별도 파일 예시):
// styles/cardGrid.css.ts (예시) export const baseCardGrid = style({ display: "grid", gap: COLUMN_GAP_SM, rowGap: ROW_GAP_SM, "@media": { "(min-width: 1024px)": { gap: COLUMN_GAP_LG, rowGap: ROW_GAP_LG }, }, });app/(sub)/search/_components/card-container/card-container.css.ts (2)
5-6: 하드코딩된 간격 값 공통화/토큰화 제안.앞선 두 파일과 동일한 코멘트입니다. 간격 스케일을 상수/토큰으로 추출하여 세 파일에서 일관되게 참조하면 향후 QA 조정 시 리스크를 크게 줄일 수 있습니다.
라인 치환 예시:
- gap: "0.8rem", - rowGap: "2.4rem", + gap: COLUMN_GAP_SM, + rowGap: ROW_GAP_SM,
19-20: 사소한 일관성: 속성 순서 통일 제안.이 파일은 1024px 블록에서 gap/rowGap → gridTemplateColumns 순서인데, 다른 두 파일은 gridTemplateColumns → gap/rowGap 순서입니다. 기능 차이는 없지만 팀 코딩 컨벤션 측면에서 정렬을 맞추면 읽기 쉬워집니다.
shared/ui/popup/popup-cancel-creation/index.tsx (1)
21-24: 내비게이션/닫기 순서 안정화 또는 replace 고려
router.push후close()를 호출하면 라우팅 타이밍에 따라 언마운트된 컴포넌트로 setState가 전파될 여지가 있습니다. 우선순위를 바꿔 먼저close()로 상태를 정리하고 라우팅하는 편이 안전합니다. 돌아가기 허용이 불필요하다면replace도 고려해 주세요.다음과 같이 순서만 교체:
- router.push(PATH.EXPLORE); - close(); + close(); + router.push(PATH.EXPLORE);또는 히스토리 정리를 원하시면:
- router.push(PATH.EXPLORE); + router.replace(PATH.EXPLORE);shared/ui/popup/popup-cancel-creation/popup-cancel-creation.css.ts (2)
7-8: 여백 하드코딩 대신 토큰 사용 검토
"0.6rem"은 매직 넘버에 가깝습니다. 디자인 시스템에 spacing 토큰이 있다면 해당 토큰 사용을 권장드립니다(예:themeVars.space.s6등). 토큰이 없다면 공용 spacing 유틸 도입을 고려해 주세요.
14-16: 공통 팝업 콘텐츠 타이포 사이즈 variant 관리 검토 권장스크립트 실행 결과, 팝업 내 폰트 크기 사용 현황은 다음과 같습니다.
- shared/ui/popup/popup.css.ts: 기본
content– F14- shared/ui/popup/popup-intro/popup-intro.css.ts: F16(34행), F14(61행)
- shared/ui/popup/popup-report/popup-report.css.ts: F16(30행)
- shared/ui/popup/popup-exit-lettie/popup-exit-lettie.css.ts: F16(27행·34행·46행)
- shared/ui/popup/popup-cancel-creation/popup-cancel-creation.css.ts: F16(15행)
- shared/ui/popup/popup-confirm-letter/popup-confirm-letter.css.ts: F16(17행)
여러 팝업에서 F16 override가 반복 적용되고 있어, 공용
popup.css.ts에 styleVariants(sm: F14 / md: F16) 형태로 관리하도록 전환하면 일관성과 재사용성이 높아집니다.현 PR에서는 기존 구현 유지 OK, 추후 다음과 같은 방식으로 리팩토링을 검토해 주세요:
// shared/ui/popup/popup.css.ts import { style, styleVariants } from "@vanilla-extract/css"; import { themeVars } from "@/shared/styles/base/theme.css"; export const contentBase = style({ color: themeVars.color.white[40], marginBottom: "2.8rem", display: "flex", flexDirection: "column", gap: "0.4rem", }); export const contentTypography = styleVariants({ sm: themeVars.text.F14, md: themeVars.text.F16, }); // 사용 예: // className={`${popupStyles.contentBase} ${popupStyles.contentTypography.md}`}shared/ui/chip/index.tsx (1)
3-3: 불필요한 import 공백 정리 및chipSize토큰 사용 현황
- 파일:
shared/ui/chip/index.tsx
• 변경 전:• 변경 후:import { chipBase, chipVariant } from "./chip.css";import { chipBase, chipVariant } from "./chip.css";./chip.css경로는 vanilla-extract 설정 및 TypeScript 모듈 해석 옵션에서 일반적으로 문제없으나, 로컬에서 한번 빌드 확인 부탁드립니다.chipSize토큰(shared/ui/chip/chip.css.ts라인 27~)은 정의되어 있으나,
•shared/ui/chip/index.tsx에 import 및 적용 로직이 없으며
•<Chip size=…>prop 사용 흔적도 없습니다.
따라서:
– size 옵션을 도입할 예정이라면
ChipProps에size?: keyof typeof chipSize추가className에chipSize[size]를 조합
– 현재 필요 없는 기능이라면 정의된chipSize스타일을 제거하는 것을 권장드립니다.shared/ui/chip/chip.stories.tsx (3)
97-107: AllVariants 스토리: 중복 제거 및 사이즈 문구 제거
size지원이 사라졌으므로 “Small/Default” 문구는 혼선을 줍니다. 또한 동일 variant를 두 번 렌더링하는 중복을 줄이면 유지보수성이 좋아집니다.아래처럼 매핑 렌더링으로 간결화하고 라벨을 정리하는 것을 제안드립니다.
- <Chip variant="purple"> - Small Purple - </Chip> - <Chip variant="purple"> - Default Purple - </Chip> - <Chip variant="gray"> - Small Gray - </Chip> - <Chip variant="gray"> - Default Gray - </Chip> + {(["purple", "gray"] as const).map((v) => ( + <Chip key={v} variant={v}> + {v === "purple" ? "Purple" : "Gray"} + </Chip> + ))}
13-15: 문서 설명에서 미지원 속성(size) 언급 제거스토리북 문서 설명에 여전히
Small, Default사이즈 지원 문구가 남아 있어 사용자 혼선을 유발합니다. 현재 컴포넌트 스펙에 맞게 문구를 정리해 주세요.- "텍스트나 태그를 표시하는데 사용되는 작은 UI 요소입니다. Purple과 Gray 두 가지 variant와 Small, Default 두 가지 size를 지원합니다.\n\n", + "텍스트나 태그를 표시하는 작은 UI 요소입니다. 현재 Purple과 Gray 두 가지 variant를 지원합니다.\n\n",
80-92: Size 제거와 불일치하는 스토리 이름 정리
Small,SmallGray스토리는 더 이상 의미가 맞지 않습니다. 이름 및 라벨을 현재 스펙에 맞게 정리하거나 통합해 주세요.-export const Small: Story = { +export const PurpleExample: Story = { args: { - children: "Small Chip", + children: "Purple Chip", variant: "purple", }, }; -export const SmallGray: Story = { +export const GrayExample: Story = { args: { - children: "Small Gray", + children: "Gray Chip", variant: "gray", }, };shared/ui/dropdown/dropdown.css.ts (3)
5-15: 애니메이션 키프레임의 스케일링 강도를 완화하거나 유지보수성을 높이는 방향 제안현재 scale(0.5) → scale(1) 전환은 시각적 임팩트가 큰 대신, 텍스트 렌더링 블러·가독성 저하가 있을 수 있습니다. 선호도와 디자인 가이드에 따라 스케일을 완화하거나(예: 0.95~0.98), translate+opacity만 사용하는 것도 고려해주세요.
스케일을 완화하는 예시:
const slideIn = keyframes({ from: { opacity: 0, - transform: "translateY(-10px) scale(0.5)", + transform: "translateY(-10px) scale(0.98)", }, to: { opacity: 1, transform: "translateY(0) scale(1)", }, }); const slideOut = keyframes({ from: { opacity: 1, transform: "translateY(0) scale(1)", }, to: { opacity: 0, - transform: "translateY(-10px) scale(0.5)", + transform: "translateY(-10px) scale(0.98)", }, });Also applies to: 16-25
38-51: 애니메이션 성능·접근성 보완: will-change, reduced-motion 대응 추가 제안열림 애니메이션 자체는 적절합니다. 다만 성능(합성 레이어)과 모션 민감 사용자 배려를 위해 will-change와 prefers-reduced-motion 대응을 권장합니다.
export const dropdownContent = style({ position: "absolute", right: 0, top: "100%", display: "flex", width: "18rem", flexDirection: "column", backgroundColor: themeVars.color.black[70], padding: "0.6rem", borderRadius: "15px", boxShadow: "0px 4px 16px 0px rgba(0, 0, 0, 0.10)", animation: `${slideIn} 0.2s ease-out`, transformOrigin: "top right", + willChange: "transform, opacity", + "@media": { + "(prefers-reduced-motion: reduce)": { + animation: "none", + }, + }, });참고: 닫힘 시 slideOut이 slideIn을 덮어쓰는 것은 동일 특이성일 때 CSS 선언 순서에 좌우됩니다(현재 파일 순서상 OK). 단, 바닐라-익스트랙트 빌드 결과에서도 이 순서가 유지되는지 브라우저별로 한 번만 확인 부탁드립니다.
54-56: 닫힘 중 포인터 차단 및 모션 감소 대응 추가 제안닫힘 애니메이션 동안 사용자 상호작용을 차단하면 안전합니다. 또한 reduced-motion에서도 애니메이션을 끄는 것을 권장합니다.
export const dropdownContentClosing = style({ animation: `${slideOut} 0.15s ease-in`, + pointerEvents: "none", + "@media": { + "(prefers-reduced-motion: reduce)": { + animation: "none", + }, + }, });shared/ui/dropdown/index.tsx (1)
142-151: 콘텐츠 컨테이너에 role 및 가시성 상태 노출 권장ul 아래 button에 role="menuitem"을 사용 중이므로 컨테이너는 role="menu"가 적합합니다. 닫힘 전 애니메이션 동안 스크린 리더에 노출을 막기 위해 aria-hidden 처리도 권장합니다.
- <ul + <ul + role="menu" + aria-hidden={isClosing} className={cn( styles.dropdownContent, isClosing? styles.dropdownContentClosing: '', className )} >shared/types/api/letter.ts (1)
35-35: objectKey 필드 optional 처리 및 빈 문자열 전송 방지 제안
- 기존
objectKeys잔여 참조는 검색 결과 없으므로 모두 정리된 상태입니다.- 다만
objectKey: ""패턴이 2곳에서 발견되었습니다.
• app/(sub)/capsule-detail/_components/write-modal/index.tsx:51
• app/(sub)/capsule-detail/_components/write-modal/index.tsx:77- 서버 계약이 “파일 미첨부 시 해당 필드 생략”이라면, 빈 문자열(
"") 전송은 400 에러나 서버 에러를 유발할 수 있습니다.- 안전한 처리 방안
또는 폼 기본값을
- 인터페이스를 optional로 변경
export interface WriteLetterReq { capsuleId: string; content: string; - objectKey: string; + objectKey?: string; from: string; }- 전송 로직에서 값 존재 시에만 포함
const payload: WriteLetterReq = { capsuleId, content, from, ...(objectKey && { objectKey }), };undefined로 두어, 빈 문자열 대신undefined가 전송되도록 수정해주세요.app/(sub)/capsule-detail/_components/write-modal/write-modal.css.ts (1)
72-75: title 버튼에 flex 적용은 OK. 단, disabled 상태 hover 차단 및 포커스 가시성 보완 필요버튼에
display: "flex"로 중앙 정렬된 것은 적절합니다. 다만 현재:hover스타일이 있어 비활성화(disabled) 시에도 시각적 피드백이 남을 수 있고, 키보드 접근성을 위한:focus-visible스타일이 없습니다. 아래와 같이 보완해 주세요.export const title = style({ display: "flex", alignItems: "center", justifyContent: "center", ...themeVars.text.B1, color: themeVars.color.purple[10], margin: 0, ":hover": { background: themeVars.color.black["80"], }, padding: "1rem 1.2rem", borderRadius: "8px", transition: "all 0.3s ease", zIndex: 1, selectors: { - "&:disabled": { + "&:disabled": { color: themeVars.color.white[30], cursor: "not-allowed", }, + // disabled 상태에서는 hover 효과 제거 + "&:disabled:hover": { + background: "transparent", + }, + // 키보드 포커스 가시성 + "&:focus-visible": { + outline: `2px solid ${themeVars.color.purple[10]}`, + outlineOffset: "2px", + }, }, });app/(sub)/capsule-detail/_components/write-modal/index.tsx (4)
47-53: defaultValues의 objectKey 빈 문자열 초기값 재검토파일 미첨부가 기본 시나리오라면
objectKey에""를 넣는 대신 undefined로 두는 편이 안전합니다. 서버가 빈 문자열을 유효값으로 처리하지 않으면 불필요한 검증 실패가 날 수 있습니다. 타입을 optional로 전환(파일: shared/types/api/letter.ts)하고, 초기값에서 제외하거나undefined로 두는 것을 권장합니다.const { handleSubmit, setValue, getValues, control, reset, formState: { errors }, } = useForm<WriteLetterReq>({ defaultValues: { capsuleId: capsuleData.result.id.toString(), content: "", from: "", - objectKey: "", + // 파일 미첨부 기본값: 포함하지 않음(또는 undefined) + // objectKey: undefined, }, });
71-84: 모달 오픈 시 폼 초기화 useEffect 의존성 주의
[isOpen]만 의존하면 의도대로 “열릴 때마다 초기화”는 되지만, ESLint(react-hooks/exhaustive-deps) 경고가 발생할 수 있습니다. 경고를 허용하지 않는 설정이라면 다음 두 가지 중 택1을 권장합니다.
- 최소한
reset,capsuleData.result.id,removeImage는 안정적으로 변하지 않더라도 의존성에 포함.- 또는 규칙을 이 위치에서만 명시적으로 무시.
- }, [isOpen]); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [isOpen]);또는:
- }, [isOpen]); + }, [isOpen, reset, removeImage, capsuleData.result.id]);두 번째 방법은
uploadedImageUrl까지 넣으면 업로드 상태 변화에 의해 불필요한 초기화가 발생할 수 있으니 포함하지 마세요.
124-129: 제출 버튼 대기 상태 접근성 보완(aria 속성 추가 권장)스피너 렌더링은 좋습니다. 스크린리더를 위해 busy 상태를 명시하면 더 견고해집니다.
- <button - type="submit" - className={styles.title} - disabled={(isPending || isUploading)} - > - {(isPending || isUploading) ? <PulseLoader color="#FFFFFF" size={5}/> : '편지담기'} - </button> + <button + type="submit" + className={styles.title} + disabled={(isPending || isUploading)} + aria-busy={(isPending || isUploading)} + aria-disabled={(isPending || isUploading)} + > + {(isPending || isUploading) + ? <PulseLoader color="#FFFFFF" size={5} /> + : '편지담기'} + </button>
73-78: reset 시 objectKey 빈 문자열 초기화 동일 이슈위 defaultValues와 동일하게, 파일 미첨부 시에는
objectKey를 생략하거나undefined로 초기화하는 것이 안전합니다. 서버 스펙에 따라 에러 가능성이 있습니다.reset({ capsuleId: capsuleData.result.id.toString(), content: "", from: "", - objectKey: "", + // objectKey: undefined, });shared/api/mutations/capsule.ts (3)
12-22: 생성 후 invalidateQueries(all) 사용: 안전하지만 과도한 재요청 가능
capsuleQueryKeys.all()전체 무효화는 간단하고 안전하지만, 리스트/상세/검색 등 모든 화면에서 불필요한 재요청이 발생할 수 있습니다. 생성 직후 필요한 키만 타겟팅하거나, 완료 화면으로의 내비게이션 타이밍에 맞춰 필요한 쿼리만 무효화하는 쪽이 더 효율적입니다.- onSuccess: () => { - queryClient.invalidateQueries({ queryKey: capsuleQueryKeys.all() }); - }, + onSuccess: () => { + // 예: 내 캡슐 목록과 탐색 리스트만 무효화 + queryClient.invalidateQueries({ queryKey: capsuleQueryKeys.lists() }); + queryClient.invalidateQueries({ queryKey: capsuleQueryKeys.my() }); + },필요 시 상세 키 무효화도 상황에 맞게 추가하세요.
35-37: 좋아요 토글에서 전체 무효화 대신 낙관적 업데이트/타겟 무효화 고려좋아요 토글은 사용자 체감 성능이 중요한 액션입니다. 전체 무효화는 화면 깜빡임과 과도한 네트워크를 유발할 수 있어, 낙관적 업데이트 또는 현재 화면 컨텍스트의 키만 무효화하는 방식을 권장합니다.
낙관적 업데이트 예시(요지):
onMutate에서 캐시 스냅샷 저장 및 즉시 토글onError에서 롤백onSettled에서 타겟 키만 무효화원하시면 현재 리스트/상세 데이터 구조에 맞춘 구체 코드를 제안드리겠습니다.
44-49: 캡슐 탈퇴 후 전체 무효화도 동일 고려사항탈퇴 시 리스트/마이 목록 영향만 명확하다면 해당 키만 무효화하는 것이 효율적입니다. 반대로 글로벌 갱신이 필요하다면 현재 방식 유지도 무방합니다(안전성 우선).
- onSuccess: () => { - queryClient.invalidateQueries({ queryKey: capsuleQueryKeys.all() }); - }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: capsuleQueryKeys.my() }); + queryClient.invalidateQueries({ queryKey: capsuleQueryKeys.lists() }); + },
📜 Review details
Configuration used: .coderabbit.yaml
Review profile: CHILL
Plan: Pro
💡 Knowledge Base configuration:
- MCP integration is disabled by default for public repositories
- Jira integration is disabled by default for public repositories
- Linear integration is disabled by default for public repositories
You can enable these sources in your CodeRabbit configuration.
📒 Files selected for processing (17)
app/(main)/explore/_components/card-container/card-container.css.ts(2 hunks)app/(main)/my-capsule/_components/card-container/card-container.css.ts(2 hunks)app/(sub)/capsule-detail/_components/modal/index.tsx(0 hunks)app/(sub)/capsule-detail/_components/write-modal/index.tsx(5 hunks)app/(sub)/capsule-detail/_components/write-modal/write-modal.css.ts(1 hunks)app/(sub)/create-capsule/_components/steps/intro-step/index.tsx(2 hunks)app/(sub)/create-capsule/page.tsx(3 hunks)app/(sub)/search/_components/card-container/card-container.css.ts(2 hunks)shared/api/mutations/capsule.ts(2 hunks)shared/types/api/letter.ts(1 hunks)shared/ui/chip/chip.stories.tsx(1 hunks)shared/ui/chip/index.tsx(1 hunks)shared/ui/dropdown/dropdown.css.ts(2 hunks)shared/ui/dropdown/index.tsx(3 hunks)shared/ui/popup/popup-cancel-creation/index.tsx(1 hunks)shared/ui/popup/popup-cancel-creation/popup-cancel-creation.css.ts(1 hunks)shared/ui/popup/popup-confirm-letter/popup-confirm-letter.css.ts(0 hunks)
💤 Files with no reviewable changes (2)
- app/(sub)/capsule-detail/_components/modal/index.tsx
- shared/ui/popup/popup-confirm-letter/popup-confirm-letter.css.ts
🧰 Additional context used
🧬 Code graph analysis (5)
app/(sub)/create-capsule/page.tsx (1)
shared/hooks/use-overlay.ts (1)
useOverlay(7-46)
shared/ui/chip/index.tsx (2)
shared/ui/chip/chip.css.ts (2)
chipVariant(16-25)chipBase(5-14)shared/utils/cn.ts (1)
cn(3-5)
shared/api/mutations/capsule.ts (4)
shared/types/api/capsule.ts (2)
CreateCapsuleReq(1-7)CreateCapsuleRes(9-14)shared/api/api-client.ts (1)
apiClient(50-65)shared/constants/endpoints.ts (1)
ENDPOINTS(3-41)shared/api/queries/capsule.ts (1)
capsuleQueryKeys(14-34)
shared/ui/popup/popup-cancel-creation/popup-cancel-creation.css.ts (3)
shared/ui/popup/popup-warning-capsule/popup-warning-capsule.css.ts (1)
continueButton(8-10)shared/ui/popup/popup-warning-letter/popup-warning-letter.css.ts (1)
continueButton(8-10)shared/ui/popup/popup.css.ts (1)
content(27-34)
shared/ui/dropdown/index.tsx (1)
shared/utils/cn.ts (1)
cn(3-5)
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (3)
- GitHub Check: test
- GitHub Check: deploy
- GitHub Check: storybook-deploy
🔇 Additional comments (11)
app/(main)/my-capsule/_components/card-container/card-container.css.ts (1)
22-23: 1024px 이상에서 간격 복원 처리 LGTM.큰 화면에서 카드 밀집도를 완화하는 의도가 명확하고 일관성 있습니다.
app/(main)/explore/_components/card-container/card-container.css.ts (1)
20-21: 1024px 브레이크포인트에서의 간격 재정의 LGTM.그리드 컬럼 수(4)와 간격을 함께 조정해 가독성/밀도를 균형 있게 맞춥니다.
shared/ui/popup/popup-cancel-creation/index.tsx (1)
15-17: Popup.ContentclassName전달 및 우선순위 확인 완료
스크립트 결과, Popup.Content 컴포넌트가 최상위<div>에cn(styles.content, className)형태로 기본 스타일(styles.content) 뒤에 전달된className을 정상 병합하고 있음을 확인했습니다.
–shared/ui/popup/index.tsx에서const PopupContent = ({ children, className }: PopupProps) => { return <div className={cn(styles.content, className)}>{children}</div>; };→
className이 최상위 DOM에 그대로 전달됨
–shared/ui/popup/popup.css.ts의export const content = style({ ...themeVars.text.F14, /* … */ });→ 기본 content 스타일은 F14
– 팝업 캔슬 생성 컴포넌트(popup-cancel-creation)에서 import되는styles.content(F16)는 이후 로드되어 동일한 CSS 특이도 하에 우선 적용따라서 기본 스타일과 충돌 없이, 의도한 대로 F16이 우선 적용됩니다. 이상 없습니다.
shared/ui/chip/index.tsx (1)
15-18: 클래스 합성 순서 적절 — 소비자 override 보장됨
className={cn(chipBase, chipVariant[variant], className)}에서 사용자className을 마지막에 둬서 override 가능성이 담보됩니다. 현재 요구사항에 부합하며 유지보수성 측면에서도 깔끔합니다. LGTM.shared/ui/dropdown/dropdown.css.ts (3)
27-31: wrapper의 borderRadius 적용 위치 확인 필요dropdownWrapper에 borderRadius를 주셨는데, 실제 라운딩은 dropdownContent에 이미 존재합니다. wrapper에 배경이 없고 overflow도 컨트롤하지 않는다면 시각적 효과가 없을 수 있어요. 의도된 스타일인지 한 번 확인 부탁드립니다.
43-43: 폭 18rem 조정 LGTM리스트 콘텐츠 길이를 고려한 적절한 너비 조정으로 보입니다.
61-61: 터치 타깃 확장 LGTM높이를 4.8rem로 늘려 터치 접근성이 개선되었습니다.
shared/ui/dropdown/index.tsx (2)
44-56: 애니메이션 중 토글 가드 도입 LGTMisAnimating 가드로 중복 토글/레이스 컨디션을 잘 막았습니다.
84-85: 외부 클릭 감지 조건부 활성화 LGTM닫힘 애니메이션 중 중복 호출을 isAnimating 가드로 막고 있어 안정적입니다.
app/(sub)/capsule-detail/_components/write-modal/index.tsx (2)
65-68: 업로드 콜백 연결은 적절함 — 제거 시 정합성도 보장되는지 확인
onObjectKeyChange로 form state 동기화하는 접근 좋습니다. 이미지 제거 시(removeImage)objectKey도 함께 비우도록 훅에서 보장하는지 확인해 주세요. 훅 내부에서 처리하지 않는다면 여기서도 초기화가 필요합니다.원하시면
useImageUpload훅 시그니처/동작을 확인해서 제거 시setValue("objectKey", undefined)까지 일원화하도록 리팩터 제안 드릴게요.
10-10: 확인 완료:react-spinners패키지가dependencies에 추가되어 있습니다 (버전 ^0.17.0). 런타임 모듈 해석 에러는 발생하지 않을 것으로 보입니다.
| // 로딩 화면 2초 동안 지속 | ||
| useEffect(() => { | ||
| if (isPending) { | ||
| setShowLoading(true); | ||
| } else if (showLoading && !isPending) { | ||
| const timer = setTimeout(() => { | ||
| setShowLoading(false); | ||
| setStep("complete"); | ||
| }, 2000); | ||
|
|
||
| return () => clearTimeout(timer); | ||
| } | ||
| }, [isPending, showLoading]); | ||
|
|
There was a problem hiding this comment.
에러 시에도 2초 후 강제로 완료 단계로 진행되는 버그
현재 로직은 isPending이 false가 되면(=성공/실패 모두) showLoading이 true인 경우 2초 후 setStep("complete")를 호출합니다. 서버 에러여도 완료 단계로 이동해 빈 화면이 될 수 있습니다(capsuleInfo 없음). 성공/실패를 구분해 타이머를 동작시켜야 합니다.
다음과 같이 isSuccess, isError를 활용해 분기해 주세요.
- const { mutate: createCapsuleMutate, isPending } = useCreateCapsule();
+ const { mutate: createCapsuleMutate, isPending, isSuccess, isError } = useCreateCapsule();
- // 로딩 화면 2초 동안 지속
- useEffect(() => {
- if (isPending) {
- setShowLoading(true);
- } else if (showLoading && !isPending) {
- const timer = setTimeout(() => {
- setShowLoading(false);
- setStep("complete");
- }, 2000);
-
- return () => clearTimeout(timer);
- }
- }, [isPending, showLoading]);
+ // 로딩 화면: 요청 중에는 표시, 성공 시에만 2초 유지 후 완료 단계로 이동, 에러 시 해제
+ useEffect(() => {
+ if (isPending) {
+ setShowLoading(true);
+ return;
+ }
+ if (showLoading && isSuccess) {
+ const timer = setTimeout(() => {
+ setShowLoading(false);
+ setStep("complete");
+ }, 2000);
+ return () => clearTimeout(timer);
+ }
+ if (isError) {
+ setShowLoading(false);
+ }
+ }, [isPending, isSuccess, isError, showLoading, setStep]);또한 onSubmit의 mutate 옵션에 onError 콜백을 추가해 에러 시 사용자 피드백(토스트/모달)을 제공하는 것을 권장합니다.
📝 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.
| // 로딩 화면 2초 동안 지속 | |
| useEffect(() => { | |
| if (isPending) { | |
| setShowLoading(true); | |
| } else if (showLoading && !isPending) { | |
| const timer = setTimeout(() => { | |
| setShowLoading(false); | |
| setStep("complete"); | |
| }, 2000); | |
| return () => clearTimeout(timer); | |
| } | |
| }, [isPending, showLoading]); | |
| // 파일 상단 어딘가에 위치 | |
| const { mutate: createCapsuleMutate, isPending, isSuccess, isError } = useCreateCapsule(); | |
| // 로딩 화면: 요청 중에는 표시, 성공 시에만 2초 유지 후 완료 단계로 이동, 에러 시 해제 | |
| - useEffect(() => { | |
| - if (isPending) { | |
| - setShowLoading(true); | |
| - } else if (showLoading && !isPending) { | |
| - const timer = setTimeout(() => { | |
| - setShowLoading(false); | |
| - setStep("complete"); | |
| - }, 2000); | |
| - | |
| - return () => clearTimeout(timer); | |
| - } | |
| useEffect(() => { | |
| if (isPending) { | |
| setShowLoading(true); | |
| return; | |
| } | |
| if (showLoading && isSuccess) { | |
| const timer = setTimeout(() => { | |
| setShowLoading(false); | |
| setStep("complete"); | |
| }, 2000); | |
| return () => clearTimeout(timer); | |
| } | |
| if (isError) { | |
| setShowLoading(false); | |
| } | |
| }, [isPending, isSuccess, isError, showLoading, setStep]); |
| useEffect(() => { | ||
| if (isClosing) { | ||
| const timer = setTimeout(() => { | ||
| setOpen(false); | ||
| setIsClosing(false); | ||
| setIsAnimating(false); | ||
| }, 150); | ||
|
|
||
| return () => clearTimeout(timer); | ||
| } else if (open) { | ||
| // 열기 애니메이션 완료 후 | ||
| const timer = setTimeout(() => { | ||
| setIsAnimating(false); | ||
| }, 150); | ||
|
|
||
| return () => clearTimeout(timer); | ||
| } | ||
| }, [isClosing, open]); | ||
|
|
There was a problem hiding this comment.
🛠️ Refactor suggestion
열림 애니메이션 타이밍 불일치(200ms vs 150ms) → 눈속임/플리커 가능성
CSS는 slideIn 0.2s인데, 열림 완료 타이머는 150ms입니다. isAnimating이 너무 일찍 해제되어 중간 상태에서 재토글이 가능해지는 등 UX 글리치가 날 수 있습니다. 최소한 200ms로 맞춰주세요. 장기적으로는 onAnimationEnd 기반으로 전환하는 것을 권장합니다.
} else if (open) {
// 열기 애니메이션 완료 후
const timer = setTimeout(() => {
setIsAnimating(false);
- }, 150);
+ }, 200);추가 제안(선택): onAnimationEnd를 활용하면 CSS 변경에도 JS 타이밍을 자동으로 따라갑니다. 필요 시 핸들러 도입 방안을 도와드리겠습니다.
📝 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(() => { | |
| if (isClosing) { | |
| const timer = setTimeout(() => { | |
| setOpen(false); | |
| setIsClosing(false); | |
| setIsAnimating(false); | |
| }, 150); | |
| return () => clearTimeout(timer); | |
| } else if (open) { | |
| // 열기 애니메이션 완료 후 | |
| const timer = setTimeout(() => { | |
| setIsAnimating(false); | |
| }, 150); | |
| return () => clearTimeout(timer); | |
| } | |
| }, [isClosing, open]); | |
| useEffect(() => { | |
| if (isClosing) { | |
| const timer = setTimeout(() => { | |
| setOpen(false); | |
| setIsClosing(false); | |
| setIsAnimating(false); | |
| }, 150); | |
| return () => clearTimeout(timer); | |
| } else if (open) { | |
| // 열기 애니메이션 완료 후 | |
| const timer = setTimeout(() => { | |
| setIsAnimating(false); | |
| }, 200); | |
| return () => clearTimeout(timer); | |
| } | |
| }, [isClosing, open]); |
🤖 Prompt for AI Agents
In shared/ui/dropdown/index.tsx around lines 65 to 83, the open-case animation
timeout is set to 150ms while the CSS slideIn animation is 200ms, causing
isAnimating to be cleared too early; change the opening setTimeout delay from
150 to 200ms so isAnimating is toggled after the CSS animation completes
(longer-term consider switching to onAnimationEnd to drive this instead of
hardcoded timeouts).
|
This pull request (commit
|
There was a problem hiding this comment.
Actionable comments posted: 0
🧹 Nitpick comments (5)
shared/ui/dropdown/dropdown.css.ts (5)
5-14: 열림 애니메이션의 스케일 값 완화 제안 (튀는 감 감소)초기 스케일 0.5 → 1 전환은 시각적으로 과격해 보일 수 있습니다. 미세한 트랜스폼으로도 충분히 역동감을 주면서 가독성을 유지할 수 있어요.
아래처럼 변경을 고려해 주세요:
const slideIn = keyframes({ from: { opacity: 0, - transform: "translateY(-10px) scale(0.5)", + transform: "translateY(-6px) scale(0.98)", }, to: { opacity: 1, transform: "translateY(0) scale(1)", }, });
16-25: 닫힘 애니메이션도 동일한 톤으로 정렬열림과 닫힘의 트랜지션 감이 다르면 이질적으로 느껴질 수 있습니다. slideIn 수정 시 slideOut도 같은 강도로 맞추는 것을 권장합니다.
const slideOut = keyframes({ from: { opacity: 1, transform: "translateY(0) scale(1)", }, to: { opacity: 0, - transform: "translateY(-10px) scale(0.5)", + transform: "translateY(-6px) scale(0.98)", }, });
38-51: 모션 감소 대응(prefers-reduced-motion) + 페인트 최적화 제안현재 항상 애니메이션이 적용됩니다. 접근성 관점에서 모션 최소화를 지원하고, 페인트 힌트를 추가하면 미세하게나마 성능에 도움이 됩니다. 또한 width를 18rem로 축소하면서 whiteSpace: "nowrap"이 유지되므로 긴 라벨에서 잘림 가능성이 있습니다(아래 dropdownItem 코멘트에 ellipsis 제안 포함).
export const dropdownContent = style({ position: "absolute", right: 0, top: "100%", display: "flex", width: "18rem", flexDirection: "column", backgroundColor: themeVars.color.black[70], padding: "0.6rem", borderRadius: "15px", boxShadow: "0px 4px 16px 0px rgba(0, 0, 0, 0.10)", - animation: `${slideIn} 0.15s ease-out`, + animation: `${slideIn} 0.15s ease-out`, transformOrigin: "top right", + willChange: "transform, opacity", + "@media": { + "(prefers-reduced-motion: reduce)": { + animation: "none", + transform: "none", + }, + }, });
58-73: 긴 라벨 처리와 키보드 접근성 보강
- nowrap이므로 긴 텍스트가 넘칠 수 있어 ellipsis/overflow 추가를 권장합니다.
- 키보드 탐색 시 :focus-visible에서도 hover와 동일한 피드백이 있으면 접근성이 좋아집니다.
export const dropdownItem = style({ display: "flex", width: "100%", height: "4.8rem", padding: "1.6rem 2rem", whiteSpace: "nowrap", alignItems: "center", ...themeVars.text.F16, borderRadius: "8px", color: themeVars.color.white[100], + overflow: "hidden", + textOverflow: "ellipsis", + outline: "none", selectors: { "&:hover": { backgroundColor: themeVars.color.white[5], }, + "&:focus-visible": { + backgroundColor: themeVars.color.white[5], + }, }, });참고: height 4.8rem은(루트 10px 기준 48px) 터치 타겟 권장 높이(≥44px)를 만족합니다. 현재 루트 폰트 사이즈 설정에 따라 실제 px 값이 달라질 수 있으니, 디자인 시스템 기준과 일치하는지 한 번만 확인 부탁드립니다.
54-56: Dropdown 닫힘 애니메이션 개선 제안
Base + Closing 클래스 적용 확인
cn(styles.dropdownContent, isClosing ? styles.dropdownContentClosing : '', className)구문으로 base 클래스가 유지된 채 closing 클래스만 추가되고 있어, 의도한 대로 동작하고 있습니다.애니메이션 종료 후 상태 전환 처리 추가 검토
현재onAnimationEnd핸들러가 없어 애니메이션 완료 시점에 언마운트나 상태 전환이 이루어지고 있는지 확인이 필요합니다. 임의의setTimeout대신onAnimationEnd로 명시적 처리를 해두면, 타이밍 이슈나 애니메이션 중간 클릭으로 인한 UX 어색함을 방지할 수 있습니다.CSS 개선 제안
깜빡임 방지(fill-mode), 클릭 차단, transform-origin 고정, reduced-motion 대응을 추가하면 UX가 더욱 견고해집니다:export const dropdownContentClosing = style({ - animation: `${slideOut} 0.15s ease-in`, + animation: `${slideOut} 0.15s ease-in`, + animationFillMode: "forwards", // 애니메이션 종료 후 스타일 유지 + pointerEvents: "none", // 닫힘 중 클릭 차단 + transformOrigin: "top right", // base와 동일한 기준점 고정 + willChange: "transform, opacity", // 브라우저 최적화 힌트 + "@media": { + "(prefers-reduced-motion: reduce)": { + animation: "none", // 리듀스 모션 대응 + opacity: 0, + }, + }, });
- Index.tsx 수정 방향
위와 같이<ul className={cn( styles.dropdownContent, isClosing ? styles.dropdownContentClosing : "", className )} onAnimationEnd={() => { /* 애니메이션 종료 후 상태 변경 로직 */ }} >onAnimationEnd를 추가해 언마운트나 상태 플래그 전환을 처리하면, 애니메이션 완전 종료 보장이 가능합니다.
📜 Review details
Configuration used: .coderabbit.yaml
Review profile: CHILL
Plan: Pro
💡 Knowledge Base configuration:
- MCP integration is disabled by default for public repositories
- Jira integration is disabled by default for public repositories
- Linear integration is disabled by default for public repositories
You can enable these sources in your CodeRabbit configuration.
📒 Files selected for processing (1)
shared/ui/dropdown/dropdown.css.ts(2 hunks)
🧰 Additional context used
🧬 Code graph analysis (1)
shared/ui/dropdown/dropdown.css.ts (1)
shared/styles/base/theme.css.ts (1)
themeVars(14-14)
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (2)
- GitHub Check: test
- GitHub Check: deploy
🔇 Additional comments (1)
shared/ui/dropdown/dropdown.css.ts (1)
2-2: keyframes 임포트 추가 LGTMVanilla Extract의 keyframes 사용을 위한 임포트는 적절합니다. 추가적인 사이드이펙트 없음 확인했습니다.
| if (event.target === event.currentTarget) { | ||
| onClose(); | ||
| } | ||
| }; |
There was a problem hiding this comment.
오버레이 클릭시 닫히는 기능 삭제한 이유가 궁금합니다..!
There was a problem hiding this comment.
편지쓰기 모달 안닫히게 해달라고 qa 들어왔어요!
There was a problem hiding this comment.
아하 LetterDetailModal에서도 쓰고 있어서.. 요거 props로 받도록 해보겠슴다
| queryKey: capsuleQueryKeys.detail(id), | ||
| }); | ||
| onSuccess: () => { | ||
| queryClient.invalidateQueries({ queryKey: capsuleQueryKeys.all() }); |
There was a problem hiding this comment.
요거 id에서 all로 무효화 하게 된 이유가 궁금합니당
There was a problem hiding this comment.
detail, my, lists 모두 무효화가 필요해서, 그냥 한번에 all로 해줬습니당
📌 Summary
📚 Tasks
Summary by CodeRabbit
New Features
Bug Fixes / Behavior
Style