Skip to content

Commit b0c7f15

Browse files
authored
QA: 편지 관련 수정사항 진행 (#193)
* feat: letterlist infinitequery로 변경 * feat: grid-layout 무한 스크롤 구현 * feat: stack-layout swipe prefetch 구현 * feat: image skeleton 필요한 부분 추가 * feat: 모달 컴포넌트에 overlay 클릭 시 닫기 기능 props 추가
1 parent a4ca568 commit b0c7f15

11 files changed

Lines changed: 206 additions & 40 deletions

File tree

app/(sub)/capsule-detail/[invite-code]/[id]/letters/_components/grid-layout/index.tsx

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,21 @@
11
import type { ImageUrl, Letter } from "@/shared/types/api/letter";
2+
import LoadingSpinner from "@/shared/ui/loading-spinner";
23
import GridLetterCard from "../grid-letter-card";
34
import * as styles from "./grid-layout.css";
45

56
interface GridLayoutProps {
67
letters: Letter[];
8+
footerRef: (element: HTMLDivElement | null) => void;
79
imageUrls: ImageUrl[];
10+
isFetchingNextPage: boolean;
811
}
912

10-
const GridLayout = ({ letters, imageUrls }: GridLayoutProps) => {
13+
const GridLayout = ({
14+
letters,
15+
footerRef,
16+
imageUrls,
17+
isFetchingNextPage,
18+
}: GridLayoutProps) => {
1119
return (
1220
<div className={styles.container}>
1321
<div className={styles.grid}>
@@ -24,6 +32,14 @@ const GridLayout = ({ letters, imageUrls }: GridLayoutProps) => {
2432
);
2533
})}
2634
</div>
35+
<div style={{ minHeight: "1px", textAlign: "center" }} ref={footerRef}>
36+
{isFetchingNextPage && (
37+
<div style={{ padding: "20px" }}>
38+
<LoadingSpinner loading={true} size={20} />
39+
<div style={{ marginTop: "8px" }}>Loading more...</div>
40+
</div>
41+
)}
42+
</div>
2743
</div>
2844
);
2945
};

app/(sub)/capsule-detail/[invite-code]/[id]/letters/_components/grid-letter-card/grid-letter-card.css.ts

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { themeVars } from "@/shared/styles/base/theme.css";
22
import { screen } from "@/shared/styles/tokens/screen";
3-
import { style } from "@vanilla-extract/css";
3+
import { keyframes, style } from "@vanilla-extract/css";
44

55
export const card = style({
66
position: "relative",
@@ -30,12 +30,26 @@ export const card = style({
3030
}),
3131
});
3232

33+
const shimmer = keyframes({
34+
"0%": { backgroundPosition: "-100% 0" },
35+
"100%": { backgroundPosition: "100% 0" },
36+
});
37+
3338
export const image = style({
3439
width: "100%",
3540
height: "100%",
3641
objectFit: "cover",
3742
borderRadius: "10px",
3843
overflow: "hidden",
44+
45+
selectors: {
46+
'&[data-loaded="false"]': {
47+
backgroundColor: themeVars.color.black["90_bg"],
48+
backgroundImage: themeVars.color.gradient.darkgray_bg_horizontal,
49+
backgroundSize: "200% 100%",
50+
animation: `${shimmer} 4s infinite`,
51+
},
52+
},
3953
});
4054

4155
export const content = style({

app/(sub)/capsule-detail/[invite-code]/[id]/letters/_components/grid-letter-card/index.tsx

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
1+
import { useOverlay } from "@/shared/hooks/use-overlay";
12
import type { Letter } from "@/shared/types/api/letter";
23
import HoverMotion from "@/shared/ui/motion/hover-motion";
34
import Image from "next/image";
4-
import { useOverlay } from "@/shared/hooks/use-overlay";
55
import LetterDetailModal from "../letter-detail-modal";
66
import * as styles from "./grid-letter-card.css";
77

@@ -34,6 +34,10 @@ const GridLetterCard = ({ letter, imageUrl }: LetterCardProps) => {
3434
className={styles.image}
3535
src={imageUrl}
3636
alt="편지 이미지"
37+
data-loaded="false"
38+
onLoad={(event) => {
39+
event.currentTarget.setAttribute("data-loaded", "true");
40+
}}
3741
/>
3842
)}
3943
<div className={styles.content}>

app/(sub)/capsule-detail/[invite-code]/[id]/letters/_components/letter-detail-modal/index.tsx

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ const LetterDetailModal = ({
2323
overlayClassName={styles.modalOverlay}
2424
contentClassName={styles.modalContent}
2525
fullScreenOnMobile={false}
26+
closeOnOverlayClick={true}
2627
>
2728
<section className={styles.container}>
2829
{imageUrl && (
@@ -32,6 +33,10 @@ const LetterDetailModal = ({
3233
width={300}
3334
height={300}
3435
className={styles.image}
36+
data-loaded="false"
37+
onLoad={(event) => {
38+
event.currentTarget.setAttribute("data-loaded", "true");
39+
}}
3540
/>
3641
)}
3742
<div className={styles.contentWrapper}>

app/(sub)/capsule-detail/[invite-code]/[id]/letters/_components/letter-detail-modal/letter-detail-modal.css.ts

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { themeVars } from "@/shared/styles/base/theme.css";
22
import { screen } from "@/shared/styles/tokens/screen";
3-
import { style } from "@vanilla-extract/css";
3+
import { keyframes, style } from "@vanilla-extract/css";
44

55
export const modalOverlay = style({
66
alignItems: "center",
@@ -38,12 +38,27 @@ export const contentWrapper = style({
3838
flex: 1,
3939
});
4040

41+
const shimmer = keyframes({
42+
"0%": { backgroundPosition: "-100% 0" },
43+
"100%": { backgroundPosition: "100% 0" },
44+
});
45+
4146
export const image = style({
4247
flexShrink: 0,
4348
width: "100%",
4449
height: "auto",
4550
objectFit: "cover",
4651
borderRadius: "14px",
52+
53+
selectors: {
54+
'&[data-loaded="false"]': {
55+
backgroundColor: themeVars.color.black["90_bg"],
56+
backgroundImage: themeVars.color.gradient.darkgray_bg_horizontal,
57+
backgroundSize: "200% 100%",
58+
animation: `${shimmer} 5s infinite`,
59+
},
60+
},
61+
4762
...screen.md({
4863
width: "30rem",
4964
height: "30rem",

app/(sub)/capsule-detail/[invite-code]/[id]/letters/_components/stack-layout/index.tsx

Lines changed: 52 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,9 @@
33
import Left from "@/shared/assets/icon/left.svg";
44
import Right from "@/shared/assets/icon/right.svg";
55
import type { Letter } from "@/shared/types/api/letter";
6+
import type {
7+
InfiniteQueryObserverResult,
8+
} from "@tanstack/react-query";
69
import { AnimatePresence, motion } from "motion/react";
710
import { useEffect, useLayoutEffect, useRef, useState } from "react";
811
import StackLetterCard from "../stack-letter-card";
@@ -25,30 +28,35 @@ interface ImageUrl {
2528

2629
interface StackLayoutProps {
2730
letters: Letter[];
31+
letterCount: number;
2832
imageUrls: ImageUrl[];
33+
fetchNextPage: () => Promise<InfiniteQueryObserverResult<any, Error>>;
34+
hasNextPage: boolean;
35+
isFetchingNextPage: boolean;
2936
}
3037

31-
const StackLayout = ({ letters, imageUrls }: StackLayoutProps) => {
38+
const StackLayout = ({
39+
letters,
40+
letterCount,
41+
imageUrls,
42+
fetchNextPage,
43+
hasNextPage,
44+
isFetchingNextPage,
45+
}: StackLayoutProps) => {
3246
const [currentIndex, setCurrentIndex] = useState(0);
47+
const currentIndexRef = useRef(0);
3348
const [direction, setDirection] = useState(0);
3449
const [visibleLetters, setVisibleLetters] = useState<VisibleLetter[]>([]);
3550
const [isAnimating, setIsAnimating] = useState(false);
3651
const timerRef = useRef<NodeJS.Timeout | null>(null);
3752

38-
useEffect(() => {
39-
return () => {
40-
if (timerRef.current) {
41-
clearTimeout(timerRef.current);
42-
}
43-
};
44-
}, []);
45-
4653
useLayoutEffect(() => {
4754
const maxCards = Math.min(3, letters.length - currentIndex);
4855
const newVisibleLetters: VisibleLetter[] = [];
4956

5057
for (let i = 0; i < maxCards; i++) {
5158
const letterIndex = currentIndex + i;
59+
5260
newVisibleLetters.push({
5361
letter: letters[letterIndex],
5462
stackIndex: i,
@@ -61,11 +69,38 @@ const StackLayout = ({ letters, imageUrls }: StackLayoutProps) => {
6169
setVisibleLetters(newVisibleLetters);
6270
}, [currentIndex, letters]);
6371

72+
useEffect(() => {
73+
currentIndexRef.current = currentIndex;
74+
}, [currentIndex]);
75+
76+
useEffect(() => {
77+
if (!hasNextPage || isFetchingNextPage) return;
78+
79+
const shouldLoadNext =
80+
currentIndexRef.current >= 8 && (currentIndexRef.current - 8) % 20 === 0;
81+
if (shouldLoadNext) {
82+
const timer = setTimeout(() => {
83+
fetchNextPage();
84+
}, 500);
85+
return () => clearTimeout(timer);
86+
}
87+
}, [currentIndex, hasNextPage, isFetchingNextPage, fetchNextPage]);
88+
89+
useEffect(() => {
90+
return () => {
91+
if (timerRef.current) {
92+
clearTimeout(timerRef.current);
93+
}
94+
};
95+
}, []);
96+
6497
const nextLetter = () => {
6598
if (currentIndex < letters.length - 1 && !isAnimating) {
6699
setIsAnimating(true);
67100
setDirection(1);
68-
setCurrentIndex((prev) => prev + 1);
101+
const nextIndex = currentIndex + 1;
102+
setCurrentIndex(nextIndex);
103+
currentIndexRef.current = nextIndex;
69104

70105
timerRef.current = setTimeout(() => {
71106
setDirection(0);
@@ -102,7 +137,7 @@ const StackLayout = ({ letters, imageUrls }: StackLayoutProps) => {
102137
</button>
103138

104139
<div className={styles.stackContainer}>
105-
<AnimatePresence mode="popLayout">
140+
<AnimatePresence mode="popLayout" initial={false}>
106141
{visibleLetters.map(({ letter, key }, index) => {
107142
const animationProps = createStackAnimations(
108143
direction,
@@ -123,7 +158,11 @@ const StackLayout = ({ letters, imageUrls }: StackLayoutProps) => {
123158
left: 0,
124159
}}
125160
>
126-
<StackLetterCard letter={letter} imageUrl={imageUrl} />
161+
<StackLetterCard
162+
letter={letter}
163+
imageUrl={imageUrl}
164+
disabled={isAnimating}
165+
/>
127166
</motion.div>
128167
);
129168
})}
@@ -144,7 +183,7 @@ const StackLayout = ({ letters, imageUrls }: StackLayoutProps) => {
144183

145184
<div className={styles.pagination}>
146185
<span className={styles.paginationCurrent}>{currentIndex + 1}</span>
147-
<span className={styles.paginationTotal}> / {letters.length}</span>
186+
<span className={styles.paginationTotal}> / {letterCount}</span>
148187
</div>
149188
</div>
150189
);

app/(sub)/capsule-detail/[invite-code]/[id]/letters/_components/stack-letter-card/index.tsx

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,24 @@
1+
import { useOverlay } from "@/shared/hooks/use-overlay";
12
import type { Letter } from "@/shared/types/api/letter";
23
import Image from "next/image";
3-
import { useOverlay } from "@/shared/hooks/use-overlay";
44
import LetterDetailModal from "../letter-detail-modal";
55
import * as styles from "./stack-letter-card.css";
66

77
interface LetterCardProps {
88
letter: Letter;
99
imageUrl?: string | null;
10+
disabled?: boolean;
1011
}
1112

12-
const StackLetterCard = ({ letter, imageUrl }: LetterCardProps) => {
13+
const StackLetterCard = ({
14+
letter,
15+
imageUrl,
16+
disabled = false,
17+
}: LetterCardProps) => {
1318
const { open } = useOverlay();
1419

1520
const handleClick = () => {
21+
if (disabled) return;
1622
open(({ isOpen, close }) => (
1723
<LetterDetailModal
1824
letter={letter}
@@ -28,13 +34,19 @@ const StackLetterCard = ({ letter, imageUrl }: LetterCardProps) => {
2834
<div className={styles.contentWrapper}>
2935
{imageUrl && (
3036
<Image
37+
key={imageUrl}
3138
width={240}
3239
height={240}
3340
className={styles.image}
3441
src={imageUrl}
3542
alt="편지 이미지"
43+
data-loaded="false"
44+
onLoad={(event) => {
45+
event.currentTarget.setAttribute("data-loaded", "true");
46+
}}
3647
/>
3748
)}
49+
3850
<p
3951
className={
4052
imageUrl ? styles.contentWithImage : styles.contentWithoutImage

app/(sub)/capsule-detail/[invite-code]/[id]/letters/_components/stack-letter-card/stack-letter-card.css.ts

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { themeVars } from "@/shared/styles/base/theme.css";
22
import { screen } from "@/shared/styles/tokens/screen";
3-
import { style } from "@vanilla-extract/css";
3+
import { keyframes, style } from "@vanilla-extract/css";
44

55
export const card = style({
66
background: themeVars.color.gradient.darkgray_op,
@@ -34,13 +34,27 @@ export const contentWrapper = style({
3434
}),
3535
});
3636

37+
const shimmer = keyframes({
38+
"0%": { backgroundPosition: "-100% 0" },
39+
"100%": { backgroundPosition: "100% 0" },
40+
});
41+
3742
export const image = style({
3843
width: "16rem",
3944
height: "16rem",
4045
objectFit: "cover",
4146
borderRadius: "14px",
4247
overflow: "hidden",
4348

49+
selectors: {
50+
'&[data-loaded="false"]': {
51+
backgroundColor: themeVars.color.black["90_bg"],
52+
backgroundImage: themeVars.color.gradient.darkgray_bg_horizontal,
53+
backgroundSize: "200% 100%",
54+
animation: `${shimmer} 5s infinite`,
55+
},
56+
},
57+
4458
...screen.md({
4559
width: "24rem",
4660
height: "24rem",

0 commit comments

Comments
 (0)