Skip to content

Commit 859d69b

Browse files
authored
Merge pull request #140 from wafflestudio/133-feature-story
스토리 구현(조회수 제외)
2 parents 0053fac + b53e04e commit 859d69b

File tree

19 files changed

+982
-133
lines changed

19 files changed

+982
-133
lines changed
6.26 KB
Loading

src/entities/story/api/getStoryFeed.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@ import type { StoryFeedItem } from '@/entities/story/model/types'
55
export async function getStoryFeed(): Promise<StoryFeedItem[]> {
66
const response = await instance.get('api/v1/stories/feed')
77
const raw = await response.json()
8+
89
const parsed = StoryFeedResponseSchema.parse(raw)
9-
return parsed.data
10+
11+
return parsed.data as unknown as StoryFeedItem[]
1012
}
Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,20 @@
11
import { useQuery } from '@tanstack/react-query'
2-
import { getStoryFeed } from '@/entities/story/api/getStoryFeed'
2+
import ky from 'ky'
3+
import type { StoryFeedItem, StoryResponse } from '../types'
34

4-
export function useStoryFeedQuery() {
5+
export const useStoryFeedQuery = () => {
56
return useQuery({
67
queryKey: ['stories', 'feed'],
7-
queryFn: getStoryFeed,
8+
queryFn: async () => {
9+
const response = await ky
10+
.get('/api/v1/stories/feed')
11+
.json<StoryResponse<StoryFeedItem[]>>()
12+
13+
if (!response.isSuccess) {
14+
throw new Error(response.message)
15+
}
16+
17+
return response.data ?? []
18+
},
819
})
920
}

src/entities/story/model/types.ts

Lines changed: 21 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,22 @@
1-
import { z } from 'zod'
2-
import { StoryFeedItemSchema } from './schema'
1+
export interface Story {
2+
id: number
3+
userId: string
4+
imageUrl: string
5+
createdAt: string
6+
expiresAt?: string
7+
}
38

4-
export type StoryFeedItem = z.infer<typeof StoryFeedItemSchema>
9+
export interface StoryFeedItem {
10+
userId: string
11+
nickname: string
12+
profileImageUrl: string | null
13+
hasUnseenStory: boolean
14+
stories: Story[]
15+
}
16+
17+
export interface StoryResponse<T> {
18+
isSuccess: boolean
19+
code: string
20+
message: string
21+
data: T
22+
}
Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
import { useState, useEffect, useCallback, useMemo } from 'react'
2+
import { useNavigate } from '@tanstack/react-router'
3+
import type { StoryFeedItem } from '@/entities/story/model/types'
4+
5+
export function useStoryViewer(
6+
storiesData: StoryFeedItem[],
7+
initialUserId: string
8+
) {
9+
const navigate = useNavigate()
10+
11+
const currentUserIndex = useMemo(() => {
12+
const index = storiesData.findIndex(
13+
(u) => String(u.userId) === String(initialUserId)
14+
)
15+
return index !== -1 ? index : 0
16+
}, [storiesData, initialUserId])
17+
18+
const [currentStoryIndex, setCurrentStoryIndex] = useState(0)
19+
const [progress, setProgress] = useState(0)
20+
const [isPaused, setIsPaused] = useState(false)
21+
const [prevUserId, setPrevUserId] = useState(initialUserId)
22+
23+
if (prevUserId !== initialUserId) {
24+
setPrevUserId(initialUserId)
25+
setCurrentStoryIndex(0)
26+
setProgress(0)
27+
}
28+
29+
const currentUser = storiesData[currentUserIndex]
30+
const currentStory = currentUser?.stories?.[currentStoryIndex]
31+
32+
const handleNext = useCallback(() => {
33+
if (!currentUser || !currentUser.stories) return
34+
35+
if (currentStoryIndex < currentUser.stories.length - 1) {
36+
setCurrentStoryIndex((prev) => prev + 1)
37+
setProgress(0)
38+
} else if (currentUserIndex < storiesData.length - 1) {
39+
const nextUser = storiesData[currentUserIndex + 1]
40+
setTimeout(() => {
41+
navigate({
42+
to: '/stories/$user_id',
43+
params: { user_id: String(nextUser.userId) },
44+
})
45+
}, 0)
46+
} else {
47+
setTimeout(() => {
48+
navigate({ to: '/' })
49+
}, 0)
50+
}
51+
}, [currentUser, currentStoryIndex, currentUserIndex, storiesData, navigate])
52+
53+
const handlePrev = useCallback(() => {
54+
if (!currentUser || !currentUser.stories) return
55+
56+
if (currentStoryIndex > 0) {
57+
setCurrentStoryIndex((prev) => prev - 1)
58+
setProgress(0)
59+
} else if (currentUserIndex > 0) {
60+
const prevUser = storiesData[currentUserIndex - 1]
61+
setTimeout(() => {
62+
navigate({
63+
to: '/stories/$user_id',
64+
params: { user_id: String(prevUser.userId) },
65+
})
66+
}, 0)
67+
}
68+
}, [currentUser, currentStoryIndex, currentUserIndex, storiesData, navigate])
69+
70+
const togglePause = useCallback(() => setIsPaused((prev) => !prev), [])
71+
72+
useEffect(() => {
73+
if (isPaused || !currentUser || !currentUser.stories) return
74+
75+
const interval = setInterval(() => {
76+
setProgress((prev) => {
77+
if (prev >= 100) {
78+
handleNext()
79+
return 100
80+
}
81+
return prev + 1
82+
})
83+
}, 50)
84+
return () => clearInterval(interval)
85+
}, [handleNext, isPaused, currentUser])
86+
87+
return {
88+
currentUser,
89+
currentStory,
90+
currentStoryIndex,
91+
currentUserIndex,
92+
progress,
93+
isPaused,
94+
handleNext,
95+
handlePrev,
96+
togglePause,
97+
}
98+
}
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
import { X } from 'lucide-react'
2+
import { useNavigate } from '@tanstack/react-router'
3+
import { STORY_VIEWER_UI } from './constants'
4+
5+
interface StoryDetailViewProps {
6+
profileName: string
7+
storyId: string
8+
}
9+
10+
export function StoryDetailView({
11+
profileName,
12+
storyId,
13+
}: StoryDetailViewProps) {
14+
const navigate = useNavigate()
15+
16+
return (
17+
<div className={STORY_VIEWER_UI.STYLES.CONTAINER}>
18+
<div className={STORY_VIEWER_UI.STYLES.VIEWER_CARD}>
19+
<div className={STORY_VIEWER_UI.STYLES.OVERLAY_TOP}>
20+
<div className={STORY_VIEWER_UI.STYLES.PROGRESS_CONTAINER}>
21+
<div className={STORY_VIEWER_UI.STYLES.PROGRESS_BAR}>
22+
<div
23+
className={STORY_VIEWER_UI.STYLES.PROGRESS_BAR_FILL}
24+
style={{ width: '30%' }}
25+
/>
26+
</div>
27+
</div>
28+
29+
<div className={STORY_VIEWER_UI.STYLES.HEADER}>
30+
<div className={STORY_VIEWER_UI.STYLES.USER_SECTION}>
31+
<div className="h-8 w-8 animate-pulse rounded-full bg-gray-600" />
32+
<div className={STORY_VIEWER_UI.STYLES.USER_INFO}>
33+
<span>{profileName}</span>
34+
<span className="text-xs opacity-60">
35+
{STORY_VIEWER_UI.MESSAGES.TIME_AGO}
36+
</span>
37+
</div>
38+
</div>
39+
40+
<div className={STORY_VIEWER_UI.STYLES.CONTROL_SECTION}>
41+
<button
42+
onClick={() => navigate({ to: '/' })}
43+
className="text-white transition-opacity hover:opacity-80"
44+
>
45+
<X className="h-7 w-7" />
46+
</button>
47+
</div>
48+
</div>
49+
</div>
50+
51+
<div className={STORY_VIEWER_UI.STYLES.CONTENT_AREA}>
52+
<div className="flex h-full w-full items-center justify-center bg-gray-800 text-white/20">
53+
<span className="text-sm font-bold">Story Image ({storyId})</span>
54+
</div>
55+
</div>
56+
</div>
57+
</div>
58+
)
59+
}
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
import { Link } from '@tanstack/react-router'
2+
import { cn } from '@/shared/lib/utils'
3+
4+
type StoryItemProps = {
5+
userId: string
6+
nickname: string
7+
profileImageUrl: string | null
8+
hasUnseenStory: boolean
9+
}
10+
11+
export function StoryItem({
12+
userId,
13+
nickname,
14+
profileImageUrl,
15+
hasUnseenStory,
16+
}: StoryItemProps) {
17+
return (
18+
<div className="flex flex-col items-center gap-2">
19+
<Link
20+
to="/stories/$user_id"
21+
params={{ user_id: userId }}
22+
className="group focus:outline-none"
23+
>
24+
<div
25+
className={cn(
26+
'flex size-20 items-center justify-center rounded-full p-[2.5px] transition group-hover:brightness-110',
27+
hasUnseenStory
28+
? 'bg-gradient-to-tr from-yellow-400 via-pink-500 to-purple-600'
29+
: 'bg-gray-300'
30+
)}
31+
>
32+
<div className="flex size-full items-center justify-center rounded-full bg-white p-[2px] dark:bg-black">
33+
<div className="flex size-full items-center justify-center overflow-hidden rounded-full bg-gray-100">
34+
{profileImageUrl ? (
35+
<img
36+
src={profileImageUrl}
37+
alt={nickname}
38+
className="h-full w-full object-cover"
39+
/>
40+
) : (
41+
<span className="text-xl font-bold text-gray-400">
42+
{nickname.trim().slice(0, 1).toUpperCase() || '?'}
43+
</span>
44+
)}
45+
</div>
46+
</div>
47+
</div>
48+
</Link>
49+
<span className="w-20 truncate text-center text-[11px] font-medium tracking-tight">
50+
{nickname}
51+
</span>
52+
</div>
53+
)
54+
}
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
interface StoryOptionsModalProps {
2+
isOpen: boolean
3+
onClose: () => void
4+
isMine: boolean
5+
onDelete?: () => void
6+
onReport?: () => void
7+
onAccountInfo?: () => void
8+
}
9+
10+
export function StoryOptionsModal({
11+
isOpen,
12+
onClose,
13+
isMine,
14+
onDelete,
15+
onReport,
16+
onAccountInfo,
17+
}: StoryOptionsModalProps) {
18+
if (!isOpen) return null
19+
20+
return (
21+
<div
22+
className="fixed inset-0 z-[100] flex items-center justify-center bg-black/60 p-4"
23+
onClick={onClose}
24+
>
25+
<div
26+
className="w-full max-w-[400px] overflow-hidden rounded-xl bg-white text-center text-[14px]"
27+
onClick={(e) => e.stopPropagation()}
28+
>
29+
{isMine ? (
30+
<>
31+
<button
32+
onClick={onDelete}
33+
className="w-full border-b border-gray-200 py-3.5 font-bold text-[#ed4956] active:bg-gray-50"
34+
>
35+
삭제
36+
</button>
37+
</>
38+
) : (
39+
<>
40+
<button
41+
onClick={onReport}
42+
className="w-full border-b border-gray-200 py-3.5 font-bold text-[#ed4956] active:bg-gray-50"
43+
>
44+
신고
45+
</button>
46+
</>
47+
)}
48+
<button
49+
onClick={onAccountInfo}
50+
className="w-full border-b border-gray-200 py-3.5 active:bg-gray-50"
51+
>
52+
이 계정 정보
53+
</button>
54+
<button onClick={onClose} className="w-full py-3.5 active:bg-gray-50">
55+
취소
56+
</button>
57+
</div>
58+
</div>
59+
)
60+
}

0 commit comments

Comments
 (0)