Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -1,13 +1,21 @@
import type { ImageUrl, Letter } from "@/shared/types/api/letter";
import LoadingSpinner from "@/shared/ui/loading-spinner";
import GridLetterCard from "../grid-letter-card";
import * as styles from "./grid-layout.css";

interface GridLayoutProps {
letters: Letter[];
footerRef: (element: HTMLDivElement | null) => void;
imageUrls: ImageUrl[];
isFetchingNextPage: boolean;
}
Comment thread
seung365 marked this conversation as resolved.

const GridLayout = ({ letters, imageUrls }: GridLayoutProps) => {
const GridLayout = ({
letters,
footerRef,
imageUrls,
isFetchingNextPage,
}: GridLayoutProps) => {
return (
<div className={styles.container}>
<div className={styles.grid}>
Expand All @@ -24,6 +32,14 @@ const GridLayout = ({ letters, imageUrls }: GridLayoutProps) => {
);
})}
</div>
<div style={{ minHeight: "1px", textAlign: "center" }} ref={footerRef}>
{isFetchingNextPage && (
<div style={{ padding: "20px" }}>
<LoadingSpinner loading={true} size={20} />
<div style={{ marginTop: "8px" }}>Loading more...</div>
</div>
)}
</div>
</div>
);
};
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { themeVars } from "@/shared/styles/base/theme.css";
import { screen } from "@/shared/styles/tokens/screen";
import { style } from "@vanilla-extract/css";
import { keyframes, style } from "@vanilla-extract/css";

export const card = style({
position: "relative",
Expand Down Expand Up @@ -30,12 +30,26 @@ export const card = style({
}),
});

const shimmer = keyframes({
"0%": { backgroundPosition: "-100% 0" },
"100%": { backgroundPosition: "100% 0" },
});

export const image = style({
width: "100%",
height: "100%",
objectFit: "cover",
borderRadius: "10px",
overflow: "hidden",

selectors: {
'&[data-loaded="false"]': {
backgroundColor: themeVars.color.black["90_bg"],
backgroundImage: themeVars.color.gradient.darkgray_bg_horizontal,
backgroundSize: "200% 100%",
animation: `${shimmer} 4s infinite`,
},
},
});

export const content = style({
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { useOverlay } from "@/shared/hooks/use-overlay";
import type { Letter } from "@/shared/types/api/letter";
import HoverMotion from "@/shared/ui/motion/hover-motion";
import Image from "next/image";
import { useOverlay } from "@/shared/hooks/use-overlay";
import LetterDetailModal from "../letter-detail-modal";
import * as styles from "./grid-letter-card.css";

Expand Down Expand Up @@ -34,6 +34,10 @@ const GridLetterCard = ({ letter, imageUrl }: LetterCardProps) => {
className={styles.image}
src={imageUrl}
alt="편지 이미지"
data-loaded="false"
onLoad={(event) => {
event.currentTarget.setAttribute("data-loaded", "true");
}}
/>
)}
<div className={styles.content}>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ const LetterDetailModal = ({
overlayClassName={styles.modalOverlay}
contentClassName={styles.modalContent}
fullScreenOnMobile={false}
closeOnOverlayClick={true}
>
<section className={styles.container}>
{imageUrl && (
Expand All @@ -32,6 +33,10 @@ const LetterDetailModal = ({
width={300}
height={300}
className={styles.image}
data-loaded="false"
onLoad={(event) => {
event.currentTarget.setAttribute("data-loaded", "true");
}}
/>
)}
<div className={styles.contentWrapper}>
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { themeVars } from "@/shared/styles/base/theme.css";
import { screen } from "@/shared/styles/tokens/screen";
import { style } from "@vanilla-extract/css";
import { keyframes, style } from "@vanilla-extract/css";

export const modalOverlay = style({
alignItems: "center",
Expand Down Expand Up @@ -38,12 +38,27 @@ export const contentWrapper = style({
flex: 1,
});

const shimmer = keyframes({
"0%": { backgroundPosition: "-100% 0" },
"100%": { backgroundPosition: "100% 0" },
});

export const image = style({
flexShrink: 0,
width: "100%",
height: "auto",
objectFit: "cover",
borderRadius: "14px",

selectors: {
'&[data-loaded="false"]': {
backgroundColor: themeVars.color.black["90_bg"],
backgroundImage: themeVars.color.gradient.darkgray_bg_horizontal,
backgroundSize: "200% 100%",
animation: `${shimmer} 5s infinite`,
},
},

...screen.md({
width: "30rem",
height: "30rem",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,9 @@
import Left from "@/shared/assets/icon/left.svg";
import Right from "@/shared/assets/icon/right.svg";
import type { Letter } from "@/shared/types/api/letter";
import type {
InfiniteQueryObserverResult,
} from "@tanstack/react-query";
import { AnimatePresence, motion } from "motion/react";
import { useEffect, useLayoutEffect, useRef, useState } from "react";
import StackLetterCard from "../stack-letter-card";
Expand All @@ -25,30 +28,35 @@ interface ImageUrl {

interface StackLayoutProps {
letters: Letter[];
letterCount: number;
imageUrls: ImageUrl[];
fetchNextPage: () => Promise<InfiniteQueryObserverResult<any, Error>>;
hasNextPage: boolean;
isFetchingNextPage: boolean;
}

const StackLayout = ({ letters, imageUrls }: StackLayoutProps) => {
const StackLayout = ({
letters,
letterCount,
imageUrls,
fetchNextPage,
hasNextPage,
isFetchingNextPage,
}: StackLayoutProps) => {
const [currentIndex, setCurrentIndex] = useState(0);
const currentIndexRef = useRef(0);
const [direction, setDirection] = useState(0);
const [visibleLetters, setVisibleLetters] = useState<VisibleLetter[]>([]);
const [isAnimating, setIsAnimating] = useState(false);
const timerRef = useRef<NodeJS.Timeout | null>(null);

useEffect(() => {
return () => {
if (timerRef.current) {
clearTimeout(timerRef.current);
}
};
}, []);

useLayoutEffect(() => {
const maxCards = Math.min(3, letters.length - currentIndex);
const newVisibleLetters: VisibleLetter[] = [];

for (let i = 0; i < maxCards; i++) {
const letterIndex = currentIndex + i;

newVisibleLetters.push({
letter: letters[letterIndex],
stackIndex: i,
Expand All @@ -61,11 +69,38 @@ const StackLayout = ({ letters, imageUrls }: StackLayoutProps) => {
setVisibleLetters(newVisibleLetters);
}, [currentIndex, letters]);

useEffect(() => {
currentIndexRef.current = currentIndex;
}, [currentIndex]);

useEffect(() => {
if (!hasNextPage || isFetchingNextPage) return;

const shouldLoadNext =
currentIndexRef.current >= 8 && (currentIndexRef.current - 8) % 20 === 0;
if (shouldLoadNext) {
const timer = setTimeout(() => {
fetchNextPage();
}, 500);
return () => clearTimeout(timer);
}
}, [currentIndex, hasNextPage, isFetchingNextPage, fetchNextPage]);

useEffect(() => {
return () => {
if (timerRef.current) {
clearTimeout(timerRef.current);
}
};
}, []);

const nextLetter = () => {
if (currentIndex < letters.length - 1 && !isAnimating) {
setIsAnimating(true);
setDirection(1);
setCurrentIndex((prev) => prev + 1);
const nextIndex = currentIndex + 1;
setCurrentIndex(nextIndex);
currentIndexRef.current = nextIndex;

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

<div className={styles.stackContainer}>
<AnimatePresence mode="popLayout">
<AnimatePresence mode="popLayout" initial={false}>
{visibleLetters.map(({ letter, key }, index) => {
const animationProps = createStackAnimations(
direction,
Expand All @@ -123,7 +158,11 @@ const StackLayout = ({ letters, imageUrls }: StackLayoutProps) => {
left: 0,
}}
>
<StackLetterCard letter={letter} imageUrl={imageUrl} />
<StackLetterCard
letter={letter}
imageUrl={imageUrl}
disabled={isAnimating}
/>
</motion.div>
);
})}
Expand All @@ -144,7 +183,7 @@ const StackLayout = ({ letters, imageUrls }: StackLayoutProps) => {

<div className={styles.pagination}>
<span className={styles.paginationCurrent}>{currentIndex + 1}</span>
<span className={styles.paginationTotal}> / {letters.length}</span>
<span className={styles.paginationTotal}> / {letterCount}</span>
</div>
</div>
);
Expand Down
Original file line number Diff line number Diff line change
@@ -1,18 +1,24 @@
import { useOverlay } from "@/shared/hooks/use-overlay";
import type { Letter } from "@/shared/types/api/letter";
import Image from "next/image";
import { useOverlay } from "@/shared/hooks/use-overlay";
import LetterDetailModal from "../letter-detail-modal";
import * as styles from "./stack-letter-card.css";

interface LetterCardProps {
letter: Letter;
imageUrl?: string | null;
disabled?: boolean;
}

const StackLetterCard = ({ letter, imageUrl }: LetterCardProps) => {
const StackLetterCard = ({
letter,
imageUrl,
disabled = false,
}: LetterCardProps) => {
const { open } = useOverlay();

const handleClick = () => {
if (disabled) return;
open(({ isOpen, close }) => (
<LetterDetailModal
letter={letter}
Expand All @@ -28,13 +34,19 @@ const StackLetterCard = ({ letter, imageUrl }: LetterCardProps) => {
<div className={styles.contentWrapper}>
{imageUrl && (
<Image
key={imageUrl}
width={240}
height={240}
className={styles.image}
src={imageUrl}
alt="편지 이미지"
data-loaded="false"
onLoad={(event) => {
event.currentTarget.setAttribute("data-loaded", "true");
}}
/>
)}

<p
className={
imageUrl ? styles.contentWithImage : styles.contentWithoutImage
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { themeVars } from "@/shared/styles/base/theme.css";
import { screen } from "@/shared/styles/tokens/screen";
import { style } from "@vanilla-extract/css";
import { keyframes, style } from "@vanilla-extract/css";

export const card = style({
background: themeVars.color.gradient.darkgray_op,
Expand Down Expand Up @@ -34,13 +34,27 @@ export const contentWrapper = style({
}),
});

const shimmer = keyframes({
"0%": { backgroundPosition: "-100% 0" },
"100%": { backgroundPosition: "100% 0" },
});

export const image = style({
width: "16rem",
height: "16rem",
objectFit: "cover",
borderRadius: "14px",
overflow: "hidden",

selectors: {
'&[data-loaded="false"]': {
backgroundColor: themeVars.color.black["90_bg"],
backgroundImage: themeVars.color.gradient.darkgray_bg_horizontal,
backgroundSize: "200% 100%",
animation: `${shimmer} 5s infinite`,
},
},

...screen.md({
width: "24rem",
height: "24rem",
Expand Down
Loading