QA: 편지 관련 수정사항 진행#193
Conversation
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
Walkthrough무한 스크롤로 편지 목록 로딩을 전환했습니다. 페이지 단의 useInfiniteQuery, 쿼리 키/옵션, 컴포넌트 props와 흐름이 업데이트되었습니다. Grid/Stack 레이아웃에 페이징 트리거가 추가되었고, 이미지 스켈레톤 로딩과 모달 오버레이 클릭 닫기 옵션이 도입되었습니다. Changes
Sequence Diagram(s)sequenceDiagram
autonumber
actor User as 사용자
participant Grid as GridLayout
participant IO as IntersectionObserver
participant Page as letters/page.tsx
participant ReactQ as useInfiniteQuery
participant API as getLetterList
User->>Grid: 스크롤 다운
Grid->>IO: footerRef 관찰
IO-->>Page: footer 교차 이벤트
alt hasNextPage && !isFetchingNextPage
Page->>ReactQ: fetchNextPage()
ReactQ->>API: getLetterList(capsuleId, pageParam, 20)
API-->>ReactQ: 페이지 데이터
ReactQ-->>Page: pages 업데이트
Page-->>Grid: letters 누적/로딩 상태 반영
else 종료 조건
IO-->>Page: 더 이상 로드 안함
end
sequenceDiagram
autonumber
actor User as 사용자
participant Stack as StackLayout
participant ReactQ as useInfiniteQuery
participant API as getLetterList
User->>Stack: 다음/이전 카드 탐색
Stack->>Stack: currentIndexRef 갱신
alt currentIndexRef ≥ 8 && index%20==0 && hasNextPage && !isFetching
Stack->>ReactQ: setTimeout(500ms) 후 fetchNextPage()
ReactQ->>API: getLetterList(nextPage)
API-->>ReactQ: 페이지 데이터
ReactQ-->>Stack: pages 업데이트
end
note over Stack: 전환 중에는 카드 disabled로 클릭 차단
sequenceDiagram
autonumber
actor User as 사용자
participant Modal as Modal
participant LDM as LetterDetailModal
User->>LDM: 이미지 카드 클릭
LDM->>Modal: isOpen=true, closeOnOverlayClick=true
User->>Modal: 오버레이 영역 클릭
Modal->>Modal: event.target===currentTarget 확인
alt closeOnOverlayClick=true
Modal-->>LDM: onClose()
LDM-->>User: 모달 닫힘
else
Modal-->>User: 유지
end
Estimated code review effort🎯 4 (Complex) | ⏱️ ~60 minutes 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
|
|
This pull request (commit
|
🚀 Storybook 배포📖 Storybook: https://683d91ab23651aa0b399e435-ddzqhbphyh.chromatic.com/ |
There was a problem hiding this comment.
Actionable comments posted: 1
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (3)
app/(sub)/capsule-detail/[invite-code]/[id]/letters/_components/letter-detail-modal/letter-detail-modal.css.ts (1)
46-66: 모바일에서 스켈레톤 높이 0 가능성 (layout shift/UX 이슈).현재
image에width: "100%",height: "auto"만 지정되어 있어 이미지 로드 전에는 고유 크기를 알 수 없어 높이가 0이 될 수 있습니다(특히 md 미만 브레이크포인트). 그 결과 스켈레톤이 보이지 않거나 레이아웃 점프가 발생할 수 있습니다.모바일에서도 로드 전 공간을 확보하도록 최소 높이나 aspect-ratio를 지정하는 것을 권장합니다.
아래처럼 기본 구간에
aspectRatio또는minHeight를 추가해 주세요(둘 중 하나 또는 병행):export const image = style({ flexShrink: 0, width: "100%", height: "auto", objectFit: "cover", borderRadius: "14px", + aspectRatio: "1", + minHeight: "16rem",app/(sub)/capsule-detail/_components/modal/index.tsx (1)
61-68: 키보드(Enter/Space) 닫기 동작도 prop에 연동 필요.현재는
closeOnOverlayClick이 false여도 오버레이에 포커스가 있을 때 Enter/Space로 닫힙니다. 클릭 정책과 불일치할 수 있어 동일 플래그로 가드하는 것을 권장합니다.- onKeyDown={(event) => { - if ( - (event.key === "Enter" || event.key === " ") && - event.target === event.currentTarget - ) { - onClose(); - } - }} + onKeyDown={(event) => { + if ( + closeOnOverlayClick && + (event.key === "Enter" || event.key === " ") && + event.target === event.currentTarget + ) { + onClose(); + } + }}app/(sub)/capsule-detail/[invite-code]/[id]/letters/_components/grid-layout/index.tsx (1)
23-31: 이미지 URL 탐색 O(n·m) → 맵으로 O(n) 개선.현재 각 렌더에서
imageUrls.find(...)가 실행되어 리스트가 커질수록 비용이 커집니다.useMemo로letterId → url맵을 구성하면 렌더 성능을 개선할 수 있습니다.
- 맵 생성(컴포넌트 상단에 추가):
// import 추가 // import { useMemo } from "react"; const imageUrlMap = useMemo(() => { return new Map(imageUrls.map((img) => [img.letterId, img.url])); }, [imageUrls]);
- 사용처 변경:
- {letters.map((letter) => { - const imageUrl = imageUrls.find( - (img) => img.letterId === letter.letterId, - )?.url; + {letters.map((letter) => { + const imageUrl = imageUrlMap.get(letter.letterId) ?? null; return ( <GridLetterCard key={letter.letterId} letter={letter} imageUrl={imageUrl} /> ); })}참고: 위 변경에 따라 파일 상단에
useMemoimport를 추가해 주세요.import { useMemo } from "react";Also applies to: 13-19
🧹 Nitpick comments (26)
app/(sub)/capsule-detail/[invite-code]/[id]/letters/_components/letter-detail-modal/letter-detail-modal.css.ts (2)
53-60: 감속 모드 지원 및 애니메이션 보간(선형) 제안.
- 접근성:
prefers-reduced-motion환경에서 애니메이션을 끄는 것이 좋습니다.- 퍼포먼스/일관성:
linear보간을 명시해 라이트 바가 일정 속도로 흐르도록 하는 것이 일반적입니다.다음과 같이 애니메이션 속성을 조정하고, 감속 모드에서 애니메이션을 해제해 주세요.
- animation: `${shimmer} 5s infinite`, + animation: `${shimmer} 5s linear infinite`,감속 모드는 vanilla-extract에서 아래처럼 적용할 수 있습니다(선택):
export const image = style({ // ... selectors: { '&[data-loaded="false"]': { // ... animation: `${shimmer} 5s linear infinite`, }, }, '@media': { '(prefers-reduced-motion: reduce)': { selectors: { '&[data-loaded="false"]': { animation: 'none' }, }, }, }, });
41-44: shimmer 키프레임 중복 정의 – 공통화 제안.동일한
shimmer가 여러 파일에서 반복 정의됩니다.shared/styles/motion.css.ts같은 곳으로 이동해 재사용하면 유지보수가 쉬워지고 애니메이션 속도(4s/5s)도 일관화할 수 있습니다.app/(sub)/capsule-detail/[invite-code]/[id]/letters/_components/stack-letter-card/stack-letter-card.css.ts (2)
49-56: 감속 모드 지원 및 애니메이션 보간(선형) 제안.그리드/모달과 동일하게 접근성(감속 모드)과 일관된 보간을 권장합니다.
- animation: `${shimmer} 5s infinite`, + animation: `${shimmer} 5s linear infinite`,선택: 감속 모드에서 애니메이션 비활성
export const image = style({ // ... selectors: { '&[data-loaded="false"]': { animation: `${shimmer} 5s linear infinite`, }, }, '@media': { '(prefers-reduced-motion: reduce)': { selectors: { '&[data-loaded="false"]': { animation: 'none' }, }, }, }, });
37-40: shimmer 키프레임 중복 정의 – 공통화 제안.여러 컴포넌트에 동일한
shimmer가 존재합니다. 공통 유틸로 추출해 재사용하면 퍼포먼스/일관성 관리에 유리합니다(예: 속도 4s vs 5s 정합).app/(sub)/capsule-detail/[invite-code]/[id]/letters/_components/grid-letter-card/grid-letter-card.css.ts (2)
45-52: 감속 모드 지원 및 애니메이션 보간(선형) 제안.그리드가 많은 카드 수를 렌더링하므로 애니메이션 비용을 줄이는 것이 중요합니다. 선형 보간 지정과 감속 모드 비활성을 권장합니다.
- animation: `${shimmer} 4s infinite`, + animation: `${shimmer} 4s linear infinite`,선택: 감속 모드에서 애니메이션 비활성
export const image = style({ // ... selectors: { '&[data-loaded="false"]': { animation: `${shimmer} 4s linear infinite`, }, }, '@media': { '(prefers-reduced-motion: reduce)': { selectors: { '&[data-loaded="false"]': { animation: 'none' }, }, }, }, });
33-36: shimmer 키프레임 중복 정의 – 공통화 제안.세 파일(그리드/스택/모달) 모두 같은 키프레임을 가집니다. 공통화하여 유지보수성을 높이고, 속도(4s/5s)도 통일하면 UX 일관성이 좋아집니다.
app/(sub)/capsule-detail/_components/modal/index.tsx (1)
39-43: 스크롤 잠금 해제 시unset대신 빈 문자열 사용 권장.
document.body.style.overflow = "unset"은 기존 스타일 상속에 영향을 줄 수 있습니다. 명시적으로 인라인 스타일만 제거하려면 빈 문자열이 안전합니다.return () => { document.removeEventListener("keydown", handleEscapeKey); - document.body.style.overflow = "unset"; + document.body.style.overflow = ""; };app/(sub)/capsule-detail/[invite-code]/[id]/letters/_components/grid-layout/index.tsx (1)
35-42: 로딩 영역 접근성 보강(스크린리더 알림).무한 스크롤 로딩 상태 변화를 스크린리더가 인지하도록
aria-live와role="status"를 권장합니다.- <div style={{ minHeight: "1px", textAlign: "center" }} ref={footerRef}> + <div + style={{ minHeight: "1px", textAlign: "center" }} + ref={footerRef} + aria-live="polite" + role="status" + >shared/api/queries/letter.ts (2)
19-26: 불필요한 재요청 억제(UX/트래픽 최적화)무한스크롤에서는 포커스 전환이나 재연결 시의 재요청이 과해질 수 있습니다. 안정적인 UX를 위해 캐싱 파라미터를 함께 지정하는 것을 권장합니다.
infiniteQueryOptions({ queryKey: letterQueryKeys.listByCapsule(capsuleId), queryFn: ({ pageParam = 0 }) => getLetterList(capsuleId, pageParam, 20), getNextPageParam: (lastPage) => { const { page, totalPages } = lastPage.result; return page < totalPages - 1 ? page + 1 : undefined; }, initialPageParam: 0, + staleTime: 60_000, // 1분 동안 신선 + gcTime: 5 * 60_000, // 5분 후 가비지 컬렉션 + refetchOnWindowFocus: false // 포커스 복귀 시 불필요한 리패치 방지
21-21: 매직 넘버 20 상수화로 의도 드러내기페이지 사이즈(20)를 상수로 추출하면 가독성과 변경 용이성이 좋아집니다.
- queryFn: ({ pageParam = 0 }) => getLetterList(capsuleId, pageParam, 20), + queryFn: ({ pageParam = 0 }) => + getLetterList(capsuleId, pageParam, LETTERS_PAGE_SIZE),파일 상단(임포트 아래)에 추가:
export const LETTERS_PAGE_SIZE = 20 as const;app/(sub)/capsule-detail/[invite-code]/[id]/letters/_components/letter-detail-modal/index.tsx (1)
36-39: Next/Image의 로딩 완료 시점 정확화 및 실패 처리
onLoad는 디코딩 완료 전에도 호출될 수 있습니다. 스켈레톤 토글은onLoadingComplete가 더 정확합니다. 또한onError에서 스켈레톤 영구 표시를 방지하는 처리를 추가하세요.- data-loaded="false" - onLoad={(event) => { - event.currentTarget.setAttribute("data-loaded", "true"); - }} + data-loaded="false" + onLoadingComplete={(img) => { + img.setAttribute("data-loaded", "true"); + }} + onError={(e) => { + (e.currentTarget as HTMLImageElement).setAttribute( + "data-loaded", + "true", + ); + }}app/(sub)/capsule-detail/[invite-code]/[id]/letters/_components/grid-letter-card/index.tsx (1)
37-40: 스켈레톤 토글: onLoadingComplete 사용 및 오류 핸들링 추가 권장그리드 카드도 디테일 모달과 동일하게 완료/실패 시점을 명확히 처리하면 UI 일관성이 좋아집니다.
- data-loaded="false" - onLoad={(event) => { - event.currentTarget.setAttribute("data-loaded", "true"); - }} + data-loaded="false" + onLoadingComplete={(img) => { + img.setAttribute("data-loaded", "true"); + }} + onError={(e) => { + (e.currentTarget as HTMLImageElement).setAttribute( + "data-loaded", + "true", + ); + }}app/(sub)/capsule-detail/[invite-code]/[id]/letters/_components/stack-letter-card/index.tsx (2)
10-21: disabled 동작의 접근성/시각적 피드백 보완
if (disabled) return;으로 동작은 막았지만, 사용자는 카드가 비활성인지 알기 어렵습니다. 컨테이너에aria-disabled/data-disabled를 반영하고 CSS로 포인터/커서 상태를 명확히 해주세요. 키보드 접근성(Enter/Space)도 비활성 시 무시되도록 함께 처리하는 것을 권장합니다.비변경 구간 예시(섹션 요소):
<section className={styles.card} onClick={handleClick} role="button" aria-disabled={disabled} data-disabled={disabled} tabIndex={disabled ? -1 : 0} />
43-46: 이미지 로딩/실패 케이스 일관 처리디테일/그리드와 동일하게
onLoadingComplete+onError로 스켈레톤 종료 시점을 통일하면 유지보수성이 올라갑니다.- data-loaded="false" - onLoad={(event) => { - event.currentTarget.setAttribute("data-loaded", "true"); - }} + data-loaded="false" + onLoadingComplete={(img) => { + img.setAttribute("data-loaded", "true"); + }} + onError={(e) => { + (e.currentTarget as HTMLImageElement).setAttribute( + "data-loaded", + "true", + ); + }}app/(sub)/capsule-detail/[invite-code]/[id]/letters/page.tsx (2)
44-51: 무한 스크롤 트리거를 조금 더 이르게 — 체감 성능 개선
threshold: 0.8은 화면에 거의 다 보일 때 페칭합니다. 이미지 프리사이닝 등을 고려하면 200~300px 앞에서 미리 페칭하는 편이 부드럽습니다.- const footerRef = useIntersectionObserver<HTMLDivElement>( + const footerRef = useIntersectionObserver<HTMLDivElement>( (entry) => { if (entry.isIntersecting && hasNextPage && !isFetchingNextPage) { fetchNextPage(); } }, - { threshold: 0.8 }, + { rootMargin: "300px 0px", threshold: 0.1 }, );
59-64: isInitialLoading 중복 조건 제거
(isLetterLoading && !letterData)가 중복되어 있습니다. 간결화하세요.- const isInitialLoading = - (isLetterLoading && !letterData) || - (isLetterLoading && !letterData) || - (isImageLoading && letters.length === 0) || - isCapsuleLoading; + const isInitialLoading = + (isLetterLoading && !letterData) || + (isImageLoading && letters.length === 0) || + isCapsuleLoading;app/(sub)/capsule-detail/[invite-code]/[id]/letters/_components/stack-layout/index.tsx (10)
29-36: fetchNextPage 타입의 any 축소 또는 제네릭화 권장현재
fetchNextPage의 반환 타입이any로 선언되어 있어 타입 안정성이 떨어집니다. 최소한unknown으로 축소하거나, 제네릭으로 노출해 상위에서 실제 타입을 전달받는 쪽이 안전합니다.다음과 같이 가볍게
unknown으로 축소하는 안:interface StackLayoutProps { letters: Letter[]; letterCount: number; imageUrls: ImageUrl[]; - fetchNextPage: () => Promise<InfiniteQueryObserverResult<any, Error>>; + fetchNextPage: () => Promise<InfiniteQueryObserverResult<unknown, Error>>; hasNextPage: boolean; isFetchingNextPage: boolean; }혹은 제네릭을 여는 안(필수는 아님):
export interface StackLayoutProps<TData = unknown, TError = Error> { letters: Letter[]; letterCount: number; imageUrls: ImageUrl[]; fetchNextPage: () => Promise<InfiniteQueryObserverResult<TData, TError>>; hasNextPage: boolean; isFetchingNextPage: boolean; }
51-51: 브라우저/Node 호환 타이머 타입
"use client"환경에서는setTimeout반환 타입이number이므로NodeJS.Timeout과 호환성 문제가 발생할 수 있습니다. 교차 환경에서 안전하게ReturnType<typeof setTimeout>을 사용하세요.- const timerRef = useRef<NodeJS.Timeout | null>(null); + const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
72-88: Ref 동기화 후 프리페치 실행 순서 OK + 매직 넘버 상수화 제안
currentIndexRef를 먼저 동기화하고, 이후 프리페치 이펙트를 실행하는 순서가 올바릅니다. 다만8,20은 의미가 드러나지 않으므로 상수화하면 가독성이 좋아집니다.- const shouldLoadNext = - currentIndexRef.current >= 8 && (currentIndexRef.current - 8) % 20 === 0; + const shouldLoadNext = + currentIndexRef.current >= PREFETCH_START_INDEX && + (currentIndexRef.current - PREFETCH_START_INDEX) % PREFETCH_STEP === 0;아래 상수를 파일 상단(컴포넌트 외부) 등에 선언:
const PREFETCH_START_INDEX = 8; const PREFETCH_STEP = 20;
97-111: 버퍼 끝에서의 사용자 입력 대응: 수동 프리페치 트리거현재는 loaded 끝(=
currentIndex >= letters.length - 1)에서 우측 버튼 클릭 시 아무 동작이 없습니다.hasNextPage가 true면 클릭을 수동 프리페치로 전환해 UX를 자연스럽게 만들 수 있습니다.- const nextLetter = () => { - if (currentIndex < letters.length - 1 && !isAnimating) { - setIsAnimating(true); - setDirection(1); - const nextIndex = currentIndex + 1; - setCurrentIndex(nextIndex); - currentIndexRef.current = nextIndex; - - timerRef.current = setTimeout(() => { - setDirection(0); - setIsAnimating(false); - timerRef.current = null; - }, 300); - } - }; + const nextLetter = () => { + if (isAnimating) return; + const atTail = currentIndex >= letters.length - 1; + if (atTail) { + if (hasNextPage && !isFetchingNextPage) { + void fetchNextPage(); + } + return; + } + setIsAnimating(true); + setDirection(1); + const nextIndex = currentIndex + 1; + setCurrentIndex(nextIndex); + currentIndexRef.current = nextIndex; + timerRef.current = setTimeout(() => { + setDirection(0); + setIsAnimating(false); + timerRef.current = null; + }, 300); + };
114-118: currentIndexRef 하향 이동 시 동기화 대칭성 확보이전 이동에서는
currentIndexRef가 즉시 갱신되지 않아(다음 렌더의 이펙트를 기다림) 극히 드문 레이스가 있을 수 있습니다. next와 대칭으로 즉시 동기화해 주세요.- setCurrentIndex((prev) => prev - 1); + const prevIndex = currentIndex - 1; + setCurrentIndex(prevIndex); + currentIndexRef.current = prevIndex;
129-137: 접근성: 아이콘 버튼에 대체 텍스트 추가아이콘만 있는 내비게이션 버튼에
aria-label/title을 추가해 스크린 리더와 툴팁 지원을 확보하는 것이 좋습니다.<button className={styles.navButton} onClick={prevLetter} disabled={isAnimating} style={{ visibility: currentIndex > 0 ? "visible" : "hidden" }} type="button" + aria-label="이전 편지" + title="이전 편지" >
146-149: 이미지 URL 탐색 비용 O(n) → O(1)로 축소 가능(미세 최적화)현재 매 렌더에서
imageUrls.find를 호출합니다. 카드가 3장이라 영향은 작지만, 맵으로 변환해두면 깔끔합니다.- const imageUrl = imageUrls.find( - (img) => img.letterId === letter.letterId, - )?.url; + const imageUrl = imageUrlMap.get(letter.letterId) ?? null;컴포넌트 상단에 맵 생성(외부 변경 없음):
import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; const imageUrlMap = useMemo( () => new Map(imageUrls.map((i) => [i.letterId, i.url] as const)), [imageUrls], );
60-66: Motion key로 index 대신 식별자 사용 권장페이지네이션/추가 로딩에서 index 기반 key는 예기치 않은 재마운트를 유발할 수 있습니다.
letter.letterId로 key를 바꾸면 더 안정적입니다.newVisibleLetters.push({ letter: letters[letterIndex], stackIndex: i, letterIndex, isTop: i === 0, - key: letterIndex, + key: letters[letterIndex].letterId, });
172-181: 오른쪽 버튼 가시성: 더 로드 가능 시 계속 보여주기로컬 버퍼의 끝에 도달했더라도
hasNextPage또는isFetchingNextPage면 버튼은 보이는 편이 UX상 자연스럽습니다(클릭 시 위의 수동 프리페치와 연계).<button className={styles.navButton} onClick={nextLetter} disabled={isAnimating} style={{ - visibility: currentIndex < letters.length - 1 ? "visible" : "hidden", + visibility: + currentIndex < letters.length - 1 || hasNextPage || isFetchingNextPage + ? "visible" + : "hidden", }} type="button" + aria-label="다음 편지" + title="다음 편지" >
5-6: 중복 타입 제거: ImageUrl은 공용 타입 재사용 권장파일 내
interface ImageUrl이shared/types/api/letter.ts의ImageUrl과 중복됩니다. 공용 타입을 import해 단일 소스로 관리하세요.-import type { Letter } from "@/shared/types/api/letter"; +import type { Letter, ImageUrl } from "@/shared/types/api/letter"; ... -interface ImageUrl { - letterId: number; - fileId: string; - url: string | null; -}Also applies to: 23-27
📜 Review details
Configuration used: Path: .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 (11)
app/(sub)/capsule-detail/[invite-code]/[id]/letters/_components/grid-layout/index.tsx(2 hunks)app/(sub)/capsule-detail/[invite-code]/[id]/letters/_components/grid-letter-card/grid-letter-card.css.ts(2 hunks)app/(sub)/capsule-detail/[invite-code]/[id]/letters/_components/grid-letter-card/index.tsx(2 hunks)app/(sub)/capsule-detail/[invite-code]/[id]/letters/_components/letter-detail-modal/index.tsx(2 hunks)app/(sub)/capsule-detail/[invite-code]/[id]/letters/_components/letter-detail-modal/letter-detail-modal.css.ts(2 hunks)app/(sub)/capsule-detail/[invite-code]/[id]/letters/_components/stack-layout/index.tsx(6 hunks)app/(sub)/capsule-detail/[invite-code]/[id]/letters/_components/stack-letter-card/index.tsx(2 hunks)app/(sub)/capsule-detail/[invite-code]/[id]/letters/_components/stack-letter-card/stack-letter-card.css.ts(2 hunks)app/(sub)/capsule-detail/[invite-code]/[id]/letters/page.tsx(4 hunks)app/(sub)/capsule-detail/_components/modal/index.tsx(4 hunks)shared/api/queries/letter.ts(1 hunks)
🧰 Additional context used
🧬 Code graph analysis (7)
app/(sub)/capsule-detail/[invite-code]/[id]/letters/_components/stack-letter-card/stack-letter-card.css.ts (2)
app/(sub)/capsule-detail/[invite-code]/[id]/letters/_components/grid-letter-card/grid-letter-card.css.ts (1)
image(38-53)app/(sub)/capsule-detail/[invite-code]/[id]/letters/_components/letter-detail-modal/letter-detail-modal.css.ts (1)
image(46-66)
app/(sub)/capsule-detail/[invite-code]/[id]/letters/_components/grid-layout/index.tsx (1)
shared/types/api/letter.ts (2)
Letter(1-10)ImageUrl(12-16)
app/(sub)/capsule-detail/[invite-code]/[id]/letters/_components/letter-detail-modal/letter-detail-modal.css.ts (2)
app/(sub)/capsule-detail/[invite-code]/[id]/letters/_components/grid-letter-card/grid-letter-card.css.ts (1)
image(38-53)app/(sub)/capsule-detail/[invite-code]/[id]/letters/_components/stack-letter-card/stack-letter-card.css.ts (1)
image(42-62)
app/(sub)/capsule-detail/[invite-code]/[id]/letters/_components/grid-letter-card/grid-letter-card.css.ts (2)
app/(sub)/capsule-detail/[invite-code]/[id]/letters/_components/letter-detail-modal/letter-detail-modal.css.ts (1)
image(46-66)app/(sub)/capsule-detail/[invite-code]/[id]/letters/_components/stack-letter-card/stack-letter-card.css.ts (1)
image(42-62)
app/(sub)/capsule-detail/[invite-code]/[id]/letters/page.tsx (2)
shared/api/queries/letter.ts (1)
letterQueryOptions(17-36)shared/hooks/use-letter-images.ts (1)
useLetterImages(6-33)
app/(sub)/capsule-detail/[invite-code]/[id]/letters/_components/stack-letter-card/index.tsx (2)
shared/types/api/letter.ts (1)
Letter(1-10)shared/hooks/use-overlay.ts (1)
useOverlay(7-46)
app/(sub)/capsule-detail/[invite-code]/[id]/letters/_components/stack-layout/index.tsx (1)
shared/types/api/letter.ts (1)
ImageUrl(12-16)
⏰ 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: storybook-deploy
- GitHub Check: deploy
- GitHub Check: test
🔇 Additional comments (12)
app/(sub)/capsule-detail/_components/modal/index.tsx (2)
47-51: 오버레이 클릭 닫기 핸들링, 조건 분기 적절합니다.
event.target === event.currentTarget로 콘텐츠 클릭과 구분하고, prop 기반으로 토글되는 점 👍
15-16: 검증 완료:closeOnOverlayClick의 기본값(false)이write-modal에 적용되고,letter-detail-modal에만true로 전달되는 것을 확인했습니다. 의도치 않은 화면에 영향이 없으므로 이번 변경사항은 안전합니다. 수고하셨습니다!app/(sub)/capsule-detail/[invite-code]/[id]/letters/_components/grid-layout/index.tsx (1)
2-2: 스피너 통합 👍무한 스크롤 피드백으로
LoadingSpinner를 하단 센티널과 결합한 점 좋습니다.shared/api/queries/letter.ts (1)
21-26: 페이지 인덱스(0/1) 기반 확인 필요
shared/types/api/letter.ts에서page: number및totalPages: number로 응답이 정의돼 있으나, 서버가 0-베이스(첫 페이지=0)인지 1-베이스(첫 페이지=1)인지 코드만으로는 알 수 없습니다. API 스펙 문서를 확인하여 아래 로직이 맞는지 검토해주세요.점검 대상
- shared/api/queries/letter.ts
initialPageParam: 0queryFn: ({ pageParam = 0 }) => getLetterList(...)getNextPageParam: return page < totalPages - 1 ? page + 1 : undefined;- shared/types/api/letter.ts
interface LetterListRes { page: number; totalPages: number; … }1-베이스 페이지 인덱스일 경우 예시 수정(diff):
- queryFn: ({ pageParam = 0 }) => getLetterList(capsuleId, pageParam, 20), + queryFn: ({ pageParam = 1 }) => getLetterList(capsuleId, pageParam, 20), - return page < totalPages - 1 ? page + 1 : undefined; + return page < totalPages ? page + 1 : undefined; - initialPageParam: 0, + initialPageParam: 1,app/(sub)/capsule-detail/[invite-code]/[id]/letters/_components/letter-detail-modal/index.tsx (1)
26-26: closeOnOverlayClick Prop 지원 확인 완료
Modal컴포넌트에서closeOnOverlayClick이 아래와 같이 정상적으로 타입 정의 및 런타임 처리되고 있음을 확인했습니다. 추가 수정은 필요하지 않습니다.
- 파일:
app/(sub)/capsule-detail/_components/modal/index.tsx
- 인터페이스 정의 (라인 15):
closeOnOverlayClick?: boolean;- 디폴트 값 설정 (라인 25):
closeOnOverlayClick = false- 오버레이 클릭 핸들러 (라인 48–50): 클릭 시
onClose()호출app/(sub)/capsule-detail/[invite-code]/[id]/letters/_components/grid-letter-card/index.tsx (1)
1-1: 임포트 정리 LGTM
useOverlay임포트를 상단으로 정리한 변경은 일관성 측면에서 좋습니다.app/(sub)/capsule-detail/[invite-code]/[id]/letters/_components/stack-letter-card/index.tsx (1)
37-37:key={imageUrl}로 리마운트해 스켈레톤 리셋 — 적절한 선택이미지 src 변경 시 스켈레톤 상태를 초기화하려는 의도에 부합합니다. 성능 영향도 미미한 수준으로 보입니다.
app/(sub)/capsule-detail/[invite-code]/[id]/letters/page.tsx (2)
36-43: 무한 쿼리 연동 자체는 깔끔합니다쿼리 키/옵션을
letterQueryOptions로 외부화하고, 페이지 집계/카운트 추출도 명확합니다. 이후 성능 관점에서 필요 시useMemo로letters/totalLetterCount계산을 감싸는 정도만 고려하시면 충분합니다.
114-121: 확인 완료: 레이아웃 컴포넌트 Prop 전파 정상
StackLayoutProps(interface) 에는
- letters, letterCount, imageUrls
- fetchNextPage: () => Promise<…>
- hasNextPage: boolean
- isFetchingNextPage: boolean
이 모두가 선언되어 있고, page.tsx에서 누락 없이 전달되고 있습니다.
내부 useEffect(fetchNextPage 호출) 로직도 의도한 대로 동작합니다.GridLayoutProps(interface) 에는
- letters, imageUrls
- footerRef: (el: HTMLDivElement | null) => void
- isFetchingNextPage: boolean
가 선언되어 있으며, page.tsx에서 footerRef와 isFetchingNextPage를 정상 전달하고 있습니다.
Intersection Observer 콜백(fetchNextPage 호출)은 page.tsx에서 관리하도록 분리되어 있습니다.따라서 현재 상태에서 Prop 선언·전달·의존 로직 모두 정확하므로 추가 변경은 필요 없습니다.
app/(sub)/capsule-detail/[invite-code]/[id]/letters/_components/stack-layout/index.tsx (3)
140-141: AnimatePresence 설정 적절
mode="popLayout"과initial={false}조합은 레이아웃 변동 시 깜빡임을 줄이고 첫 마운트 애니메이션을 억제하는데 유효합니다. 그대로 가시죠.
161-165: 전환 중 카드 인터랙션 비활성화 처리 LGTM
disabled={isAnimating}로 전환 중 상호작용을 막는 처리가 적절합니다.
185-187: 총 개수 표시를 서버 total과 동기화한 점 좋습니다
letters.length대신letterCount(total)를 사용해 UI 표시를 데이터 총량과 일치시킨 점이 적절합니다.
📌 Summary
📚 Tasks
👀 To Reviewer
stack-layout의 경우에는 8번째 card를 본 후 20개의 아이템을 불러옵니다. 그 후 28번째, 48번째, ... 를 본 후 불러오게 됩니다.
grid-layout의 경우 무한 스크롤을 적용하였습니다.
📸 Screenshot
아래는 image skeleton 입니다.
2025-08-22.5.34.32.mov
Summary by CodeRabbit
신기능
개선사항