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
77 changes: 47 additions & 30 deletions src/features/story-viewer/model/useStoryViewer.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { useState, useEffect, useCallback, useMemo } from 'react'
import { useState, useCallback, useMemo, useEffect } from 'react'
import { useNavigate } from '@tanstack/react-router'
import type { StoryFeedItem } from '@/entities/story/model/types'

Expand All @@ -16,27 +16,34 @@ export function useStoryViewer(
)
}, [storiesData, initialUserId])

const [currentStoryIndex, setCurrentStoryIndex] = useState(0)
const [progress, setProgress] = useState(0)
const [isPaused, setIsPaused] = useState(false)
const [state, setState] = useState(() => ({
userId: initialUserId,
storyIndex: 0,
progress: 0,
isPaused: false,
}))

const [prevUserId, setPrevUserId] = useState(initialUserId)

if (prevUserId !== initialUserId) {
setPrevUserId(initialUserId)
setCurrentStoryIndex(0)
setProgress(0)
setIsPaused(false)
if (state.userId !== initialUserId) {
setState({
userId: initialUserId,
storyIndex: 0,
progress: 0,
isPaused: false,
})
}

const currentUser = storiesData[currentUserIndex]
const currentStory = currentUser?.stories?.[currentStoryIndex]
const currentStory = currentUser?.stories?.[state.storyIndex]

const handleNext = useCallback(() => {
if (!currentUser) return
if (currentStoryIndex < currentUser.stories.length - 1) {
setCurrentStoryIndex((prev) => prev + 1)
setProgress(0)

if (state.storyIndex < currentUser.stories.length - 1) {
setState((prev) => ({
...prev,
storyIndex: prev.storyIndex + 1,
progress: 0,
}))
} else if (currentUserIndex < storiesData.length - 1) {
const nextUser = storiesData[currentUserIndex + 1]
navigate({
Expand All @@ -46,50 +53,60 @@ export function useStoryViewer(
} else {
navigate({ to: '/', search: { page: 1 } })
}
}, [currentUser, currentStoryIndex, currentUserIndex, storiesData, navigate])
}, [currentUser, state.storyIndex, currentUserIndex, storiesData, navigate])

const handlePrev = useCallback(() => {
if (!currentUser) return
if (currentStoryIndex > 0) {
setCurrentStoryIndex((prev) => prev - 1)
setProgress(0)

if (state.storyIndex > 0) {
setState((prev) => ({
...prev,
storyIndex: prev.storyIndex - 1,
progress: 0,
}))
} else if (currentUserIndex > 0) {
const prevUser = storiesData[currentUserIndex - 1]
navigate({
to: '/stories/$user_id',
params: { user_id: String(prevUser.userId) },
})
}
}, [currentUser, currentStoryIndex, currentUserIndex, storiesData, navigate])
}, [currentUser, state.storyIndex, currentUserIndex, storiesData, navigate])

const togglePause = useCallback(() => {
setIsPaused((prev) => !prev)
setState((prev) => ({
...prev,
isPaused: !prev.isPaused,
}))
}, [])

useEffect(() => {
if (isPaused || !currentStory) return
if (state.isPaused || !currentStory) return

const step = (INTERVAL_MS / STORY_DURATION) * 100
const timer = setInterval(() => {
setProgress((prev) => {
if (prev >= 100) {
setState((prev) => {
if (prev.progress >= 100) {
handleNext()
return 100
return prev
}
return {
...prev,
progress: prev.progress + step,
}
return prev + step
})
}, INTERVAL_MS)

return () => clearInterval(timer)
}, [isPaused, currentStoryIndex, handleNext, currentStory])
}, [state.isPaused, currentStory, handleNext])

return {
currentUser,
currentStory,
currentStoryIndex,
currentStoryIndex: state.storyIndex,
currentUserIndex,
progress,
isPaused,
progress: state.progress,
isPaused: state.isPaused,
handleNext,
handlePrev,
togglePause,
Expand Down
80 changes: 39 additions & 41 deletions src/features/story-viewer/ui/StoryViewer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import instagramLogo from '@/assets/instagram-black-logo.png'
interface StoryViewerProps {
feed: StoryFeedItem[]
userId: string
detailUser?: StoryFeedItem
isDetailLoading?: boolean
}

Expand All @@ -40,6 +41,7 @@ const formatRelativeTime = (createdAt: string) => {
export function StoryViewer({
feed,
userId,
detailUser,
isDetailLoading,
}: StoryViewerProps) {
const navigate = useNavigate()
Expand All @@ -64,25 +66,30 @@ export function StoryViewer({
togglePause,
} = useStoryViewer(feed, userId)

const viewerUser =
detailUser && String(detailUser.userId) === String(userId)
? detailUser
: currentUser

const isMine =
!isMeLoading &&
me?.userId !== undefined &&
currentUser?.userId !== undefined &&
String(me.userId) === String(currentUser.userId)
viewerUser?.userId !== undefined &&
String(me.userId) === String(viewerUser.userId)

useEffect(() => {
if (imageError && !isPaused) {
togglePause()
}
}, [imageError, isPaused, togglePause])

if (!currentUser) return null
if (!viewerUser || !currentStory) return null

const isFirstStoryOfFirstUser =
currentUserIndex === 0 && currentStoryIndex === 0
const isLastStoryOfLastUser =
currentUserIndex === feed.length - 1 &&
currentStoryIndex === (currentUser?.stories?.length ?? 0) - 1
currentStoryIndex === (viewerUser.stories.length ?? 0) - 1

const handleOpenOptions = (e: React.MouseEvent) => {
e.stopPropagation()
Expand Down Expand Up @@ -113,7 +120,7 @@ export function StoryViewer({
const handleDeleteStory = async () => {
try {
const response = await instance
.delete(`api/v1/stories/${currentStory?.id}`)
.delete(`api/v1/stories/${currentStory.id}`)
.json<{ isSuccess: boolean; code: string; message: string }>()
if (response.isSuccess) {
queryClient.invalidateQueries({ queryKey: ['stories', 'feed'] })
Expand Down Expand Up @@ -158,15 +165,15 @@ export function StoryViewer({
)}

<div className={STORY_VIEWER_UI.STYLES.VIEWER_CARD}>
{isDetailLoading && !currentStory?.imageUrl && (
{isDetailLoading && !currentStory.imageUrl && (
<div className="absolute inset-0 z-10 flex items-center justify-center bg-black">
<div className="h-8 w-8 animate-spin rounded-full border-4 border-gray-600 border-t-white" />
</div>
)}

<div className={STORY_VIEWER_UI.STYLES.OVERLAY_TOP}>
<div className={STORY_VIEWER_UI.STYLES.PROGRESS_CONTAINER}>
{currentUser.stories.map((story: Story, i: number) => (
{viewerUser.stories.map((story: Story, i: number) => (
<div
key={story.id}
className={STORY_VIEWER_UI.STYLES.PROGRESS_BAR}
Expand All @@ -189,21 +196,19 @@ export function StoryViewer({
<div className={STORY_VIEWER_UI.STYLES.HEADER}>
<Link
to="/$userId"
params={{ userId: String(currentUser.userId) }}
params={{ userId: String(viewerUser.userId) }}
className={STORY_VIEWER_UI.STYLES.USER_SECTION}
>
<img
src={currentUser.profileImageUrl ?? ''}
src={viewerUser.profileImageUrl ?? ''}
className={STORY_VIEWER_UI.STYLES.AVATAR}
alt=""
/>
<div className={STORY_VIEWER_UI.STYLES.USER_INFO}>
<span className="font-bold">{currentUser.nickname}</span>
{currentStory && (
<span className="text-[13px] font-normal opacity-60">
{formatRelativeTime(currentStory.createdAt)}
</span>
)}
<span className="font-bold">{viewerUser.nickname}</span>
<span className="text-[13px] font-normal opacity-60">
{formatRelativeTime(currentStory.createdAt)}
</span>
</div>
</Link>

Expand Down Expand Up @@ -234,37 +239,30 @@ export function StoryViewer({

<div
className={STORY_VIEWER_UI.STYLES.CONTENT_AREA}
onClick={(e: React.MouseEvent<HTMLDivElement>) => {
onClick={(e) => {
const rect = e.currentTarget.getBoundingClientRect()
setImageError(false)
if (e.clientX - rect.left < rect.width / 2) handlePrev()
else handleNext()
}}
>
{imageError ? (
<div className="flex h-full w-full flex-col items-center justify-center bg-black px-10 text-center text-white">
<p className="text-[14px] leading-relaxed font-medium">
더 이상 이용할 수 없는 콘텐츠입니다
</p>
<div className="flex h-full w-full items-center justify-center bg-black text-white">
더 이상 이용할 수 없는 콘텐츠입니다
</div>
) : (
<>
{currentStory?.imageUrl && (
<img
key={currentStory.imageUrl}
src={currentStory.imageUrl}
className="h-full w-full object-cover select-none"
alt="story"
onError={() => setImageError(true)}
referrerPolicy="no-referrer"
/>
)}
<img
src={currentStory.imageUrl}
className="h-full w-full object-cover select-none"
alt="story"
onError={() => setImageError(true)}
referrerPolicy="no-referrer"
/>

{isMine && currentStory?.viewCount !== undefined && (
<div className="absolute bottom-4 left-4 z-50 flex flex-col items-start gap-1">
<span className="text-[13px] font-semibold text-white drop-shadow-md">
{currentStory.viewCount}명이 읽음
</span>
{isMine && currentStory.viewCount !== undefined && (
<div className="absolute bottom-4 left-4 z-50 text-white">
{currentStory.viewCount}명이 읽음
</div>
)}
</>
Expand All @@ -289,7 +287,7 @@ export function StoryViewer({
isOpen={isOptionsOpen}
onClose={handleCloseOptions}
isMine={isMine}
userId={currentUser.userId}
userId={viewerUser.userId}
onReport={handleOpenReport}
onAccountInfo={handleOpenAccountInfo}
onDelete={handleOpenDeleteConfirm}
Expand All @@ -302,7 +300,7 @@ export function StoryViewer({
if (isPaused && !imageError) togglePause()
}}
onHideComment={() => {}}
nickname={currentUser.nickname}
nickname={viewerUser.nickname}
type="post"
/>
)}
Expand All @@ -313,8 +311,8 @@ export function StoryViewer({
setIsAccountInfoOpen(false)
if (isPaused && !imageError) togglePause()
}}
nickname={currentUser.nickname}
profileImageUrl={currentUser.profileImageUrl}
nickname={viewerUser.nickname}
profileImageUrl={viewerUser.profileImageUrl}
/>
)}

Expand All @@ -340,7 +338,7 @@ export function StoryViewer({
</div>
<button
onClick={handleDeleteStory}
className="w-full border-t border-gray-200 py-3 text-[14px] font-bold text-red-500 active:bg-gray-50"
className="w-full border-t border-gray-200 py-3 text-[14px] font-bold text-red-500"
>
삭제
</button>
Expand All @@ -349,7 +347,7 @@ export function StoryViewer({
setIsDeleteConfirmOpen(false)
if (isPaused && !imageError) togglePause()
}}
className="w-full border-t border-gray-200 py-3 text-[14px] active:bg-gray-50"
className="w-full border-t border-gray-200 py-3 text-[14px]"
>
취소
</button>
Expand Down
6 changes: 5 additions & 1 deletion src/routes/stories/$user_id.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,10 +21,13 @@ function RouteComponent() {

const mergedFeed = useMemo(() => {
if (!feedData) return []

if (!detailData) return feedData

return feedData.map((item) =>
String(item.userId) === String(user_id) ? detailData : item
String(item.userId) === String(user_id)
? { ...item, stories: detailData.stories }
: item
)
}, [feedData, detailData, user_id])

Expand All @@ -41,6 +44,7 @@ function RouteComponent() {
<StoryViewer
feed={mergedFeed}
userId={user_id}
detailUser={detailData}
isDetailLoading={isDetailLoading}
/>
</div>
Expand Down