|
| 1 | +import { useState, useEffect } from 'react' |
1 | 2 | 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' |
3 | 5 | import PostInfoSection from './PostInfoSection' |
4 | 6 |
|
| 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 | + |
5 | 18 | 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' }) |
7 | 20 | const navigate = useNavigate() |
8 | 21 |
|
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) |
11 | 58 | } |
12 | 59 |
|
13 | 60 | return ( |
14 | 61 | <div |
15 | 62 | className="fixed inset-0 z-[100] flex items-center justify-center bg-black/60" |
16 | 63 | onClick={handleClose} |
17 | 64 | > |
| 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 | + |
18 | 73 | <button |
19 | | - onClick={(e) => { |
20 | | - e.stopPropagation() |
21 | | - handleClose() |
22 | | - }} |
| 74 | + onClick={handleClose} |
23 | 75 | className="absolute top-6 right-6 z-[110] text-white" |
24 | 76 | > |
25 | 77 | <X className="h-10 w-10" /> |
26 | 78 | </button> |
27 | 79 |
|
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 | | - |
42 | 80 | <div |
43 | 81 | className="relative flex h-[90%] w-[95%] max-w-[1200px] overflow-hidden rounded-sm bg-white shadow-2xl" |
44 | 82 | onClick={(e) => e.stopPropagation()} |
45 | 83 | > |
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 | + |
47 | 159 | <div className="flex w-full flex-col border-l border-gray-200 bg-white md:w-[40%]"> |
48 | | - <PostInfoSection /> |
| 160 | + <PostInfoSection data={postData} /> |
49 | 161 | </div> |
50 | 162 | </div> |
51 | 163 | </div> |
|
0 commit comments