-
Notifications
You must be signed in to change notification settings - Fork 0
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Merge pull request #63 from softeerbootcamp4th/feature/12-commentary
[feat] 기대평 무한 캐러셀 구현
- Loading branch information
Showing
17 changed files
with
392 additions
and
8 deletions.
There are no files selected for viewing
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,33 @@ | ||
import useAutoCarousel from "./useAutoCarousel.js"; | ||
|
||
function AutoScrollCarousel({ speed = 1, gap = 0, children }) { | ||
const { position, ref, eventListener } = useAutoCarousel(speed); | ||
|
||
const flexStyle = | ||
"flex [&>div]:flex-shrink-0 gap-[var(--gap,0)] items-center absolute"; | ||
return ( | ||
<div className="w-full h-full overflow-hidden" {...eventListener}> | ||
<div | ||
style={{ | ||
"--gap": gap + "px", | ||
transform: `translateX(${position * -1}px)`, | ||
}} | ||
className="relative h-max touch-pan-y" | ||
> | ||
<div | ||
className={`${flexStyle} -translate-x-[calc(100%+var(--gap,0px))]`} | ||
> | ||
{children} | ||
</div> | ||
<div className={flexStyle} ref={ref}> | ||
{children} | ||
</div> | ||
<div className={`${flexStyle} translate-x-[calc(100%+var(--gap,0px))]`}> | ||
{children} | ||
</div> | ||
</div> | ||
</div> | ||
); | ||
} | ||
|
||
export default AutoScrollCarousel; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,105 @@ | ||
import { useState, useEffect, useRef, useCallback } from "react"; | ||
import useMountDragEvent from "@/common/useMountDragEvent"; | ||
|
||
const FRICTION_RATE = 0.1; | ||
const MOMENTUM_THRESHOLD = 0.6; | ||
const MOMENTUM_RATE = 0.15; | ||
|
||
function useAutoCarousel(speed = 1) { | ||
const childRef = useRef(null); | ||
const [position, setPosition] = useState(0); | ||
const [isControlled, setIsControlled] = useState(false); | ||
const [isHovered, setIsHovered] = useState(false); | ||
const timestamp = useRef(null); | ||
const dragging = useRef(false); | ||
const prevDragState = useRef({ x: 0, mouseX: 0, prevMouseX: 0 }); | ||
const momentum = useRef(speed); | ||
|
||
useEffect(() => { | ||
if (isControlled) return; | ||
|
||
let progress = true; | ||
timestamp.current = performance.now(); | ||
function animate(time) { | ||
if (childRef.current === null) return; | ||
|
||
// 마우스 뗐을 때 관성 재계산 | ||
const baseSpeed = isHovered ? 0 : speed; | ||
momentum.current -= (momentum.current - baseSpeed) * FRICTION_RATE; | ||
|
||
if (Math.abs(momentum.current, baseSpeed) < MOMENTUM_THRESHOLD) | ||
momentum.current = baseSpeed; | ||
const finalSpeed = momentum.current; | ||
|
||
// 인터벌과 실제 x 포지션 계산 | ||
const interval = performance.now() - timestamp.current; | ||
setPosition((position) => { | ||
const newPos = position + finalSpeed * interval; | ||
return newPos % childRef.current.clientWidth; | ||
}); | ||
|
||
// 타임스탬프 저장 | ||
timestamp.current = time; | ||
if (progress) requestAnimationFrame(animate); | ||
} | ||
|
||
requestAnimationFrame(animate); | ||
|
||
return () => { | ||
progress = false; | ||
}; | ||
}, [isControlled, isHovered, speed]); | ||
|
||
// 드래그 도중 함수 | ||
const onDrag = useCallback(({ x: mouseX }) => { | ||
if (!dragging.current) return; | ||
|
||
// 새로운 포지션 계산 | ||
let newPos = | ||
prevDragState.current.x - mouseX + prevDragState.current.mouseX; | ||
newPos %= childRef.current.clientWidth; | ||
setPosition(newPos); | ||
|
||
// 관성 계산 | ||
if (Math.abs(mouseX - prevDragState.current.mouseX) > 10) { | ||
momentum.current = | ||
(prevDragState.current.prevMouseX - mouseX) * MOMENTUM_RATE; | ||
} else momentum.current = 0; | ||
prevDragState.current.prevMouseX = mouseX; | ||
}, []); | ||
|
||
// 드래그 종료 함수 | ||
const onDragEnd = useCallback((e) => { | ||
if (!dragging.current) return; | ||
dragging.current = false; | ||
setIsControlled(false); | ||
if (e.pointerType === "touch") setIsHovered(false); | ||
}, []); | ||
|
||
useMountDragEvent(onDrag, onDragEnd); | ||
|
||
return { | ||
position, | ||
ref: childRef, | ||
eventListener: { | ||
onMouseEnter() { | ||
setIsControlled(true); | ||
setIsHovered(true); | ||
}, | ||
onMouseLeave() { | ||
setIsControlled(false); | ||
setIsHovered(false); | ||
}, | ||
onPointerDown(e) { | ||
setIsControlled(true); | ||
setIsHovered(true); | ||
dragging.current = true; | ||
prevDragState.current.x = position; | ||
prevDragState.current.mouseX = e.clientX; | ||
prevDragState.current.prevMouseX = e.clientX; | ||
}, | ||
}, | ||
}; | ||
} | ||
|
||
export default useAutoCarousel; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,41 @@ | ||
import AutoScrollCarousel from "../autoScrollCarousel"; | ||
|
||
function mask(string) { | ||
const len = string.length; | ||
if (len <= 1) return "*"; | ||
if (len === 2) return string[0] + "*"; | ||
return string[0] + "*".repeat(len - 2) + string[len - 1]; | ||
} | ||
|
||
function formatDate(dateString) { | ||
const date = new Date(dateString); | ||
const year = date.getFullYear(); | ||
const month = date.getMonth() + 1; | ||
const day = date.getDate(); | ||
return `${year}. ${month}. ${day}`; | ||
} | ||
|
||
function CommentCarousel({ resource }) { | ||
const comments = resource().comments; | ||
|
||
return ( | ||
<div className="w-full h-[29rem]"> | ||
<AutoScrollCarousel speed={0.1} gap={28}> | ||
{comments.map(({ id, content, userName, createdAt }) => ( | ||
<div | ||
className="w-72 h-96 mt-10 bg-neutral-50 p-8 flex flex-col justify-between gap-10 hover:scale-110 transition-transform duration-200 ease-in-out-cubic" | ||
key={id} | ||
> | ||
<p className="text-neutral-800 text-body-l">{content}</p> | ||
<div className="text-blue-400 flex flex-col gap-1"> | ||
<p className="text-body-m">{mask(userName)} 님</p> | ||
<p className="text-body-s">{formatDate(createdAt)}</p> | ||
</div> | ||
</div> | ||
))} | ||
</AutoScrollCarousel> | ||
</div> | ||
); | ||
} | ||
|
||
export default CommentCarousel; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,19 @@ | ||
function CommentCarouselError() { | ||
return ( | ||
<div className="w-full h-[29rem] flex justify-center items-center px-6"> | ||
<div className="w-full max-w-[1200px] h-96 bg-neutral-50 flex flex-col justify-center items-center gap-4"> | ||
<img | ||
src="/icons/error.svg" | ||
alt="불러오기 오류" | ||
width="120" | ||
height="120" | ||
/> | ||
<p className="text-body-l text-red-500 font-bold"> | ||
기대평 정보를 불러오지 못했어요! | ||
</p> | ||
</div> | ||
</div> | ||
); | ||
} | ||
|
||
export default CommentCarouselError; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,33 @@ | ||
import Spinner from "@/common/Spinner.jsx"; | ||
|
||
function CommentCarouselSkeleton() { | ||
return ( | ||
<div className="w-full h-[29rem] flex justify-center items-center gap-7 overflow-hidden"> | ||
<div className="flex justify-center items-center gap-7"> | ||
<div className="w-72 h-96 bg-neutral-50 flex justify-center items-center "> | ||
<Spinner /> | ||
</div> | ||
<div className="w-72 h-96 bg-neutral-50 flex justify-center items-center"> | ||
<Spinner /> | ||
</div> | ||
<div className="w-72 h-96 bg-neutral-50 flex justify-center items-center"> | ||
<Spinner /> | ||
</div> | ||
<div className="w-72 h-96 bg-neutral-50 flex justify-center items-center"> | ||
<Spinner /> | ||
</div> | ||
<div className="w-72 h-96 bg-neutral-50 flex justify-center items-center"> | ||
<Spinner /> | ||
</div> | ||
<div className="w-72 h-96 bg-neutral-50 flex justify-center items-center"> | ||
<Spinner /> | ||
</div> | ||
<div className="w-72 h-96 bg-neutral-50 flex justify-center items-center"> | ||
<Spinner /> | ||
</div> | ||
</div> | ||
</div> | ||
); | ||
} | ||
|
||
export default CommentCarouselSkeleton; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,19 @@ | ||
import Suspense from "@/common/Suspense.jsx"; | ||
import ErrorBoundary from "@/common/ErrorBoundary.jsx"; | ||
import { fetchResource } from "@/common/fetchServer.js"; | ||
import CommentCarousel from "./CommentCarousel.jsx"; | ||
import CommentCarouselSkeleton from "./CommentCarouselSkeleton.jsx"; | ||
import CommentCarouselError from "./CommentCarouselError.jsx"; | ||
|
||
function CommentCarouselView() { | ||
const resource = fetchResource("/api/v1/comment"); | ||
return ( | ||
<ErrorBoundary fallback={<CommentCarouselError />}> | ||
<Suspense fallback={<CommentCarouselSkeleton />}> | ||
<CommentCarousel resource={resource} /> | ||
</Suspense> | ||
</ErrorBoundary> | ||
); | ||
} | ||
|
||
export default CommentCarouselView; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,32 @@ | ||
import CommentCarousel from "./commentCarousel"; | ||
import decoration from "./assets/decoration.svg"; | ||
|
||
function CommentSection() { | ||
return ( | ||
<section className="w-full flex flex-col items-center py-24 lg:py-60 gap-40"> | ||
<div className="w-full flex flex-col items-center"> | ||
<div className="relative flex flex-col gap-3 lg:gap-9 text-center font-bold items-center"> | ||
<p className="text-body-m text-neutral-600 w-fit py-3 lg:py-5"> | ||
기대평 작성하기 | ||
</p> | ||
<h2 className="text-head-s lg:text-head-m text-black"> | ||
UNIQUE한 <br className="hidden sm:inline" /> | ||
IONIQ 5의 <br className="inline sm:hidden" /> | ||
<span className="sketch-line">기대평</span>을 남겨주세요 | ||
</h2> | ||
<img | ||
src={decoration} | ||
alt="heart" | ||
className="size-20 lg:size-28 absolute -top-2 sm:top-4 lg:top-10 -left-2 sm:left-12" | ||
/> | ||
</div> | ||
<p className="text-body-m sm:text-title-s text-neutral-800 font-medium mt-10"> | ||
기대평을 등록하면 추첨 이벤트의 당첨 확률이 올라갑니다. | ||
</p> | ||
<CommentCarousel /> | ||
</div> | ||
</section> | ||
); | ||
} | ||
|
||
export default CommentSection; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,41 @@ | ||
import { http, HttpResponse } from "msw"; | ||
|
||
function randArr(arr) { | ||
let idx = Math.floor(Math.random() * arr.length); | ||
return arr[idx]; | ||
} | ||
|
||
function makeLorem(min, max) { | ||
const loremipsum = | ||
"Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.".split( | ||
" ", | ||
); | ||
|
||
let result = []; | ||
let cnt = Math.floor(Math.random() * (max - min)) + min; | ||
for (let i = 0; i < cnt; i++) { | ||
result.push(randArr(loremipsum)); | ||
} | ||
return result.join(" "); | ||
} | ||
|
||
function getCommentMock() { | ||
return Array.from({ length: 20 }, (_, i) => { | ||
return { | ||
id: i * 100 + Math.floor(Math.random() * 99), | ||
content: makeLorem(5, 12), | ||
userName: makeLorem(1, 1), | ||
createdAt: new Date( | ||
Date.now() - Math.floor(Math.random() * 86400 * 60 * 1000), | ||
), | ||
}; | ||
}); | ||
} | ||
|
||
const handlers = [ | ||
http.get("/api/v1/comment", async () => { | ||
return HttpResponse.json({ comments: getCommentMock() }); | ||
}), | ||
]; | ||
|
||
export default handlers; |
Oops, something went wrong.