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
7 changes: 4 additions & 3 deletions src/app/providers/Providers.tsx
Original file line number Diff line number Diff line change
@@ -1,18 +1,19 @@
import type { PropsWithChildren } from 'react'

import { ThemeProvider } from 'next-themes'

import { SidebarProvider } from '@/shared/ui/sidebar'
import { Toaster } from '@/shared/ui/sonner'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { AuthProvider } from '@/shared/auth/AuthProvider'

const queryClient = new QueryClient()

export function Providers({ children }: PropsWithChildren) {
return (
<QueryClientProvider client={queryClient}>
<ThemeProvider attribute="class" defaultTheme="system" enableSystem>
<SidebarProvider>{children}</SidebarProvider>
<AuthProvider>
<SidebarProvider>{children}</SidebarProvider>
</AuthProvider>
<Toaster duration={1500} className="text-center" />
</ThemeProvider>
</QueryClientProvider>
Expand Down
54 changes: 27 additions & 27 deletions src/components/post/CommentItem.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ interface Comment {
content: string
profileImageUrl: string
createdAt: string
likeCount: number
liked: boolean
}

interface CommentItemProps {
Expand Down Expand Up @@ -44,39 +46,33 @@ export default function CommentItem({
setIsMenuOpen(false)
}

const displayLikeCount = comment.likeCount || 0

return (
<>
<div
className="group relative flex cursor-pointer items-start justify-between gap-3 px-1 py-1.5 select-none"
onDoubleClick={() => onDoubleClick(comment.id)}
>
<div className="flex flex-1 gap-3">
{comment.profileImageUrl ? (
<img
src={comment.profileImageUrl}
className={`${isReply ? 'h-6 w-6' : 'h-8 w-8'} shrink-0 rounded-full object-cover`}
alt=""
/>
) : (
<div
className={`${isReply ? 'h-6 w-6 text-xs' : 'h-8 w-8 text-sm'} flex shrink-0 items-center justify-center rounded-full bg-gray-200 font-semibold text-gray-500`}
>
{comment.nickname.trim().slice(0, 1).toUpperCase() || '?'}
</div>
)}
<div className="text-sm">
<div className="flex flex-1 items-start gap-3">
<img
src={comment.profileImageUrl}
className={`${isReply ? 'h-6 w-6' : 'h-8 w-8'} mt-0.5 shrink-0 rounded-full object-cover`}
alt=""
/>
<div className="text-sm leading-tight">
<span className="mr-2 font-semibold text-black">
{comment.nickname}
</span>
<span className="break-all text-black">{comment.content}</span>
<div className="mt-1 flex h-4 items-center gap-3 text-xs font-semibold text-gray-500">
<span>{timeDisplay}</span>
<button
className="hover:text-gray-900"
onClick={(e) => e.stopPropagation()}
>
좋아요
</button>

<div className="mt-[7px] flex h-4 items-center gap-3 text-xs font-semibold text-gray-500">
<span className="font-normal">{timeDisplay}</span>
{displayLikeCount > 0 && (
<span className="font-semibold text-gray-500">
좋아요 {displayLikeCount}개
</span>
)}
<button
className="hover:text-gray-900"
onClick={(e) => e.stopPropagation()}
Expand All @@ -92,14 +88,18 @@ export default function CommentItem({
</div>
</div>
</div>

<button
onClick={(e) => onHeartClick(comment.id, e)}
className="mt-1.5 flex-shrink-0"
onClick={(e) => {
e.stopPropagation()
onHeartClick(comment.id, e)
}}
className="mt-1.5 flex-shrink-0 p-1"
>
<Heart
className={`h-3 w-3 transition-colors ${
className={`h-3 w-3 transition-all ${
isLiked
? 'fill-red-500 text-red-500'
? 'scale-110 fill-[#ED4956] text-[#ED4956]'
: 'text-gray-400 hover:text-gray-600'
}`}
/>
Expand Down
212 changes: 127 additions & 85 deletions src/components/post/PostDetail.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,16 +3,33 @@ import { useParams, useLocation, useNavigate } from '@tanstack/react-router'
import { X, ChevronLeft, ChevronRight, Heart } from 'lucide-react'
import { motion, AnimatePresence } from 'framer-motion'
import PostInfoSection from './PostInfoSection'
import { instance } from '../../shared/api/ky'

export interface PostImage {
id: number
url: string
orderIndex: number
}

export interface PostData {
id: string
images: string[]
caption: string
username: string
userImage: string
createdAt: string
id: number
userId: number
nickname: string
profileImageUrl: string
content: string
albumId: number
images: PostImage[]
likeCount: number
commentCount: number
createdAt: string
updatedAt: string
liked: boolean
bookmarked: boolean
}

interface SearchParams {
returnToPath?: string
returnToSearch?: Record<string, string>
}

export default function PostDetail() {
Expand All @@ -27,17 +44,25 @@ export default function PostDetail() {
const [isAnimating, setIsAnimating] = useState(false)

useEffect(() => {
fetch(`/api/v1/posts/${postId}`)
.then((res) => res.json())
.then((json) => {
if (json.isSuccess) setPostData(json.data)
})
const fetchPost = async () => {
try {
const res = await instance
.get(`api/v1/posts/${postId}`)
.json<{ data: PostData }>()
setPostData(res.data)
} catch {
console.error('Failed to fetch post')
}
}
fetchPost()
}, [postId])

const images = postData?.images || []

const handleClose = () => {
const returnToPath = location.search.returnToPath
const returnToSearch = location.search.returnToSearch
const search = location.search as SearchParams
const returnToPath = search.returnToPath
const returnToSearch = search.returnToSearch

if (returnToPath) {
navigate({
Expand All @@ -58,13 +83,10 @@ export default function PostDetail() {

const handleDoubleLike = () => {
if (isAnimating) return

setIsAnimating(true)
const rotate = Math.floor(Math.random() * 61) - 30
setRandomRotate(rotate)
setShowHeart(true)

// 기울기 유지(0.4s) + 정방향 회전(0.3s) = 총 0.7초 후 사라짐 시작
setTimeout(() => {
setShowHeart(false)
}, 700)
Expand All @@ -91,82 +113,102 @@ export default function PostDetail() {
</button>

<div
className="relative flex h-[90%] w-[95%] max-w-[1200px] overflow-hidden rounded-sm bg-white shadow-2xl"
className="relative flex h-fit max-h-[90%] w-[95%] max-w-[1200px] overflow-hidden rounded-sm bg-white shadow-2xl"
onClick={(e) => e.stopPropagation()}
>
<div className="relative hidden w-[60%] items-center justify-center overflow-hidden bg-black md:flex">
<motion.div
className="flex h-full w-full"
animate={{ x: `-${currentIndex * 100}%` }}
transition={{ type: 'spring', stiffness: 260, damping: 26 }}
onDoubleClick={handleDoubleLike}
<div className="relative hidden w-[60%] flex-col items-center justify-center bg-black md:flex">
<div
className="relative w-full overflow-hidden"
style={{ aspectRatio: '1 / 1' }}
>
{images.map((img, idx) => (
<div
key={idx}
className="flex h-full min-w-full shrink-0 items-center justify-center"
>
<img
src={img}
alt=""
className="h-full w-full object-contain select-none"
/>
</div>
))}
</motion.div>

<AnimatePresence>
{showHeart && (
<motion.div
key="rising-heart"
initial={{ scale: 0, opacity: 1, y: 0, rotate: randomRotate }}
animate={{
scale: [0, 1.2, 1],
y: -20,
rotate: [randomRotate, randomRotate, 0],
}}
exit={{
y: -700,
transition: { duration: 0.2, ease: 'circIn' },
<motion.div
className="flex h-full w-full"
animate={{ x: `-${currentIndex * 100}%` }}
transition={{ type: 'spring', stiffness: 260, damping: 26 }}
onDoubleClick={handleDoubleLike}
>
{images.map((img) => (
<div
key={img.id}
className="flex h-full min-w-full shrink-0 items-center justify-center"
>
<img
src={img.url}
alt=""
className="h-full w-full object-cover select-none"
/>
</div>
))}
</motion.div>

<AnimatePresence>
{showHeart && (
<motion.div
key="rising-heart"
initial={{ scale: 0, opacity: 1, y: 0, rotate: randomRotate }}
animate={{
scale: [0, 1.2, 1],
y: -20,
rotate: [randomRotate, randomRotate, 0],
}}
exit={{
y: -700,
transition: { duration: 0.2, ease: 'circIn' },
}}
transition={{
duration: 0.7,
times: [0, 0.57, 1],
ease: 'easeInOut',
}}
onAnimationComplete={() => setIsAnimating(false)}
className="pointer-events-none absolute inset-0 z-20 flex items-center justify-center"
>
<Heart
className="h-32 w-32 drop-shadow-2xl"
style={{ fill: 'url(#heart-gradient)', stroke: 'none' }}
/>
</motion.div>
)}
</AnimatePresence>

{images.length > 1 && currentIndex > 0 && (
<button
type="button"
onClick={(e) => {
e.stopPropagation()
moveSlide(-1)
}}
transition={{
duration: 0.7,
times: [0, 0.57, 1],
ease: 'easeInOut',
className="absolute top-1/2 left-4 z-30 flex h-8 w-8 -translate-y-1/2 items-center justify-center rounded-full bg-white/80 shadow-md hover:bg-white"
>
<ChevronLeft className="h-5 w-5 text-black" strokeWidth={3} />
</button>
)}
{images.length > 1 && currentIndex < images.length - 1 && (
<button
type="button"
onClick={(e) => {
e.stopPropagation()
moveSlide(1)
}}
onAnimationComplete={() => setIsAnimating(false)}
className="pointer-events-none absolute z-20 flex items-center justify-center"
className="absolute top-1/2 right-4 z-30 flex h-8 w-8 -translate-y-1/2 items-center justify-center rounded-full bg-white/80 shadow-md hover:bg-white"
>
<Heart
className="h-32 w-32 drop-shadow-2xl"
style={{ fill: 'url(#heart-gradient)', stroke: 'none' }}
/>
</motion.div>
<ChevronRight className="h-5 w-5 text-black" strokeWidth={3} />
</button>
)}
</AnimatePresence>

{images.length > 1 && currentIndex > 0 && (
<button
onClick={(e) => {
e.stopPropagation()
moveSlide(-1)
}}
className="absolute left-4 z-10 flex h-8 w-8 items-center justify-center rounded-full bg-white/80 shadow-md"
>
<ChevronLeft className="h-5 w-5 text-black" strokeWidth={3} />
</button>
)}
{images.length > 1 && currentIndex < images.length - 1 && (
<button
onClick={(e) => {
e.stopPropagation()
moveSlide(1)
}}
className="absolute right-4 z-10 flex h-8 w-8 items-center justify-center rounded-full bg-white/80 shadow-md"
>
<ChevronRight className="h-5 w-5 text-black" strokeWidth={3} />
</button>
)}

{images.length > 1 && (
<div className="absolute bottom-4 left-1/2 z-30 flex -translate-x-1/2 gap-1.5">
{images.map((_, i) => (
<div
key={i}
className={`h-1.5 w-1.5 rounded-full transition-colors ${
i === currentIndex ? 'bg-white' : 'bg-white/50'
}`}
/>
))}
</div>
)}
</div>
</div>

<div className="flex w-full flex-col border-l border-gray-200 bg-white md:w-[40%]">
Expand Down
Loading