Skip to content

Commit 2c739ef

Browse files
authored
Merge pull request #38 from wafflestudio/26-feature-posts_picture
게시물 상세정보 사진 파트 pr
2 parents 04dfce8 + 09df5db commit 2c739ef

File tree

5 files changed

+193
-48
lines changed

5 files changed

+193
-48
lines changed

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@
3737
"class-variance-authority": "^0.7.1",
3838
"clsx": "^2.1.1",
3939
"embla-carousel-react": "^8.6.0",
40+
"framer-motion": "^12.26.2",
4041
"immer": "^11.1.3",
4142
"ky": "^1.14.2",
4243
"lucide-react": "^0.562.0",

src/components/post/PostDetail.tsx

Lines changed: 136 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1,51 +1,163 @@
1+
import { useState, useEffect } from 'react'
12
import { useParams, useNavigate } from '@tanstack/react-router'
2-
import { X, ChevronLeft, ChevronRight } from 'lucide-react'
3+
import { X, ChevronLeft, ChevronRight, Heart } from 'lucide-react'
4+
import { motion, AnimatePresence } from 'framer-motion'
35
import PostInfoSection from './PostInfoSection'
46

7+
export interface PostData {
8+
id: string
9+
images: string[]
10+
caption: string
11+
username: string
12+
userImage: string
13+
createdAt: string
14+
likeCount: number
15+
commentCount: number
16+
}
17+
518
export default function PostDetail() {
6-
const { profile_name } = useParams({ from: '/_app/p/$profile_name' })
19+
const { profile_name: postId } = useParams({ from: '/_app/p/$profile_name' })
720
const navigate = useNavigate()
821

9-
const handleClose = () => {
10-
navigate({ to: `/${profile_name}` })
22+
const [postData, setPostData] = useState<PostData | null>(null)
23+
const [currentIndex, setCurrentIndex] = useState(0)
24+
const [showHeart, setShowHeart] = useState(false)
25+
const [randomRotate, setRandomRotate] = useState(0)
26+
const [isAnimating, setIsAnimating] = useState(false)
27+
28+
useEffect(() => {
29+
fetch(`/api/v1/posts/${postId}`)
30+
.then((res) => res.json())
31+
.then((json) => {
32+
if (json.success) setPostData(json.data)
33+
})
34+
}, [postId])
35+
36+
const images = postData?.images || []
37+
const handleClose = () => navigate({ to: '..' })
38+
39+
const moveSlide = (step: number) => {
40+
if (images.length === 0) return
41+
setCurrentIndex((prev) =>
42+
Math.max(0, Math.min(prev + step, images.length - 1))
43+
)
44+
}
45+
46+
const handleDoubleLike = () => {
47+
if (isAnimating) return
48+
49+
setIsAnimating(true)
50+
const rotate = Math.floor(Math.random() * 61) - 30
51+
setRandomRotate(rotate)
52+
setShowHeart(true)
53+
54+
// 기울기 유지(0.4s) + 정방향 회전(0.3s) = 총 0.7초 후 사라짐 시작
55+
setTimeout(() => {
56+
setShowHeart(false)
57+
}, 700)
1158
}
1259

1360
return (
1461
<div
1562
className="fixed inset-0 z-[100] flex items-center justify-center bg-black/60"
1663
onClick={handleClose}
1764
>
65+
<svg width="0" height="0" className="absolute">
66+
<linearGradient id="heart-gradient" x1="0%" y1="0%" x2="100%" y2="100%">
67+
<stop offset="0%" stopColor="#FF3040" />
68+
<stop offset="50%" stopColor="#D300C5" />
69+
<stop offset="100%" stopColor="#FF7A00" />
70+
</linearGradient>
71+
</svg>
72+
1873
<button
19-
onClick={(e) => {
20-
e.stopPropagation()
21-
handleClose()
22-
}}
74+
onClick={handleClose}
2375
className="absolute top-6 right-6 z-[110] text-white"
2476
>
2577
<X className="h-10 w-10" />
2678
</button>
2779

28-
<button
29-
className="absolute left-4 z-[110] flex h-8 w-8 items-center justify-center rounded-full bg-white shadow-lg transition-colors hover:bg-gray-200 md:left-8"
30-
onClick={(e) => e.stopPropagation()}
31-
>
32-
<ChevronLeft className="h-5 w-5 text-black" strokeWidth={3} />
33-
</button>
34-
35-
<button
36-
className="absolute right-4 z-[110] flex h-8 w-8 items-center justify-center rounded-full bg-white shadow-lg transition-colors hover:bg-gray-200 md:right-8"
37-
onClick={(e) => e.stopPropagation()}
38-
>
39-
<ChevronRight className="h-5 w-5 text-black" strokeWidth={3} />
40-
</button>
41-
4280
<div
4381
className="relative flex h-[90%] w-[95%] max-w-[1200px] overflow-hidden rounded-sm bg-white shadow-2xl"
4482
onClick={(e) => e.stopPropagation()}
4583
>
46-
<div className="hidden w-[60%] bg-black md:flex" />
84+
<div className="relative hidden w-[60%] items-center justify-center overflow-hidden bg-black md:flex">
85+
<motion.div
86+
className="flex h-full w-full"
87+
animate={{ x: `-${currentIndex * 100}%` }}
88+
transition={{ type: 'spring', stiffness: 260, damping: 26 }}
89+
onDoubleClick={handleDoubleLike}
90+
>
91+
{images.map((img, idx) => (
92+
<div
93+
key={idx}
94+
className="flex h-full min-w-full flex-shrink-0 items-center justify-center"
95+
>
96+
<img
97+
src={img}
98+
alt=""
99+
className="h-full w-full object-contain select-none"
100+
/>
101+
</div>
102+
))}
103+
</motion.div>
104+
105+
<AnimatePresence>
106+
{showHeart && (
107+
<motion.div
108+
key="rising-heart"
109+
initial={{ scale: 0, opacity: 1, y: 0, rotate: randomRotate }}
110+
animate={{
111+
scale: [0, 1.2, 1],
112+
y: -20,
113+
rotate: [randomRotate, randomRotate, 0],
114+
}}
115+
exit={{
116+
y: -700,
117+
transition: { duration: 0.2, ease: 'circIn' },
118+
}}
119+
transition={{
120+
duration: 0.7,
121+
times: [0, 0.57, 1], // 0.4s 지점까지 기울기 유지, 이후 0.3s 동안 회전 완료
122+
ease: 'easeInOut',
123+
}}
124+
onAnimationComplete={() => setIsAnimating(false)}
125+
className="pointer-events-none absolute z-[20] flex items-center justify-center"
126+
>
127+
<Heart
128+
className="h-32 w-32 drop-shadow-2xl"
129+
style={{ fill: 'url(#heart-gradient)', stroke: 'none' }}
130+
/>
131+
</motion.div>
132+
)}
133+
</AnimatePresence>
134+
135+
{images.length > 1 && currentIndex > 0 && (
136+
<button
137+
onClick={(e) => {
138+
e.stopPropagation()
139+
moveSlide(-1)
140+
}}
141+
className="absolute left-4 z-10 flex h-8 w-8 items-center justify-center rounded-full bg-white/80 shadow-md"
142+
>
143+
<ChevronLeft className="h-5 w-5 text-black" strokeWidth={3} />
144+
</button>
145+
)}
146+
{images.length > 1 && currentIndex < images.length - 1 && (
147+
<button
148+
onClick={(e) => {
149+
e.stopPropagation()
150+
moveSlide(1)
151+
}}
152+
className="absolute right-4 z-10 flex h-8 w-8 items-center justify-center rounded-full bg-white/80 shadow-md"
153+
>
154+
<ChevronRight className="h-5 w-5 text-black" strokeWidth={3} />
155+
</button>
156+
)}
157+
</div>
158+
47159
<div className="flex w-full flex-col border-l border-gray-200 bg-white md:w-[40%]">
48-
<PostInfoSection />
160+
<PostInfoSection data={postData} />
49161
</div>
50162
</div>
51163
</div>

src/components/post/PostInfoSection.tsx

Lines changed: 28 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,27 +1,44 @@
1-
export default function PostInfoSection() {
1+
import type { PostData } from './PostDetail'
2+
3+
interface PostInfoSectionProps {
4+
data: PostData | null
5+
}
6+
7+
export default function PostInfoSection({ data }: PostInfoSectionProps) {
28
return (
39
<div className="flex h-full flex-col">
410
<div className="flex items-center gap-3 border-b border-gray-200 p-4">
5-
<div className="h-8 w-8 rounded-full bg-gray-200" />
6-
<div className="text-sm font-semibold text-black">username</div>
11+
<img
12+
src={data?.userImage || 'https://via.placeholder.com/32'}
13+
className="h-8 w-8 rounded-full object-cover"
14+
alt="profile"
15+
/>
16+
<div className="text-sm font-semibold text-black">
17+
{data?.username || 'loading...'}
18+
</div>
719
</div>
820

921
<div className="flex-1 overflow-y-auto p-4">
1022
<div className="mb-4 flex gap-3">
11-
<div className="h-8 w-8 shrink-0 rounded-full bg-gray-200" />
23+
<img
24+
src={data?.userImage || 'https://via.placeholder.com/32'}
25+
className="h-8 w-8 shrink-0 rounded-full object-cover"
26+
alt="profile"
27+
/>
1228
<div className="text-sm text-black">
13-
<span className="mr-2 font-semibold">username</span>
14-
게시물 본문 내용이 들어가는 자리입니다.
29+
<span className="mr-2 font-semibold">{data?.username}</span>
30+
{data?.caption}
1531
</div>
1632
</div>
1733
</div>
1834

19-
<div className="border-t border-gray-200 p-4">
20-
<div className="mb-2 flex gap-4 text-black">
21-
<div className="h-6 w-6 rounded-sm border-2 border-black" />
22-
<div className="h-6 w-6 rounded-sm border-2 border-black" />
35+
<div className="border-t border-gray-200 p-4 text-black">
36+
<div className="mb-1 text-sm font-semibold">
37+
좋아요 {data?.likeCount?.toLocaleString() || 0}
38+
</div>
39+
<div className="text-[10px] text-gray-500 uppercase">
40+
{data?.createdAt ? new Date(data.createdAt).toLocaleDateString() : ''}
2341
</div>
24-
<div className="text-sm font-semibold text-black">좋아요 0개</div>
2542
</div>
2643
</div>
2744
)

src/mocks/db/post.db.ts

Lines changed: 7 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -9,25 +9,19 @@ export interface Post {
99
commentCount: number
1010
}
1111

12-
export const posts: Post[] = [
12+
export const posts = [
1313
{
1414
id: '1',
15-
imageUrl: 'https://picsum.photos/id/10/800/800',
16-
caption: '첫 번째 게시물 테스트입니다.',
15+
images: [
16+
'https://picsum.photos/id/10/800/800',
17+
'https://picsum.photos/id/11/800/800',
18+
'https://picsum.photos/id/12/800/800',
19+
],
20+
caption: '여러 장의 사진 테스트입니다.',
1721
username: 'test_user',
1822
userImage: 'https://picsum.photos/id/64/50/50',
1923
createdAt: '2024-03-20T10:00:00Z',
2024
likeCount: 120,
2125
commentCount: 8,
2226
},
23-
{
24-
id: '2',
25-
imageUrl: 'https://picsum.photos/id/20/800/800',
26-
caption: '두 번째 게시물 샘플 데이터입니다.',
27-
username: 'sample_fan',
28-
userImage: 'https://picsum.photos/id/65/50/50',
29-
createdAt: '2024-03-21T15:00:00Z',
30-
likeCount: 45,
31-
commentCount: 2,
32-
},
3327
]

yarn.lock

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2586,6 +2586,15 @@ flatted@^3.2.9, flatted@^3.3.3:
25862586
resolved "https://registry.yarnpkg.com/flatted/-/flatted-3.3.3.tgz#67c8fad95454a7c7abebf74bb78ee74a44023358"
25872587
integrity sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==
25882588

2589+
framer-motion@^12.26.2:
2590+
version "12.26.2"
2591+
resolved "https://registry.yarnpkg.com/framer-motion/-/framer-motion-12.26.2.tgz#6dcae8c06aa559c978745ec5f2e7d3fbbfad8eb6"
2592+
integrity sha512-lflOQEdjquUi9sCg5Y1LrsZDlsjrHw7m0T9Yedvnk7Bnhqfkc89/Uha10J3CFhkL+TCZVCRw9eUGyM/lyYhXQA==
2593+
dependencies:
2594+
motion-dom "^12.26.2"
2595+
motion-utils "^12.24.10"
2596+
tslib "^2.4.0"
2597+
25892598
fs-extra@9.1.0:
25902599
version "9.1.0"
25912600
resolved "https://registry.yarnpkg.com/fs-extra/-/fs-extra-9.1.0.tgz#5954460c764a8da2094ba3554bf839e6b9a7c86d"
@@ -3301,6 +3310,18 @@ minimist@1.2.7:
33013310
resolved "https://registry.yarnpkg.com/minimist/-/minimist-1.2.7.tgz#daa1c4d91f507390437c6a8bc01078e7000c4d18"
33023311
integrity sha512-bzfL1YUZsP41gmu/qjrEk0Q6i2ix/cVeAhbCbqH9u3zYutS1cLg00qhrD0M2MVdCcx4Sc0UpP2eBWo9rotpq6g==
33033312

3313+
motion-dom@^12.26.2:
3314+
version "12.26.2"
3315+
resolved "https://registry.yarnpkg.com/motion-dom/-/motion-dom-12.26.2.tgz#0d9f65b45e429bb71fb17bb630d7310495b7b6b5"
3316+
integrity sha512-KLMT1BroY8oKNeliA3JMNJ+nbCIsTKg6hJpDb4jtRAJ7nCKnnpg/LTq/NGqG90Limitz3kdAnAVXecdFVGlWTw==
3317+
dependencies:
3318+
motion-utils "^12.24.10"
3319+
3320+
motion-utils@^12.24.10:
3321+
version "12.24.10"
3322+
resolved "https://registry.yarnpkg.com/motion-utils/-/motion-utils-12.24.10.tgz#73d0bead3c08c4ba2965a5f7ee39dd53f661ae7e"
3323+
integrity sha512-x5TFgkCIP4pPsRLpKoI86jv/q8t8FQOiM/0E8QKBzfMozWHfkKap2gA1hOki+B5g3IsBNpxbUnfOum1+dgvYww==
3324+
33043325
mrmime@^2.0.0:
33053326
version "2.0.1"
33063327
resolved "https://registry.yarnpkg.com/mrmime/-/mrmime-2.0.1.tgz#bc3e87f7987853a54c9850eeb1f1078cd44adddc"

0 commit comments

Comments
 (0)