Skip to content

Commit 1823b5f

Browse files
authored
Merge pull request #123 from wafflestudio/120-feature-post_modify
post 수정 기능 구현
2 parents 5315710 + 0a0fe88 commit 1823b5f

File tree

12 files changed

+505
-66
lines changed

12 files changed

+505
-66
lines changed
Lines changed: 163 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,163 @@
1+
import { useCallback, useState, useMemo } from 'react'
2+
import { Dialog, DialogContent } from '@/shared/ui/dialog'
3+
import { CheckCircle2, Loader2 } from 'lucide-react'
4+
import { toast } from 'sonner'
5+
import { instance } from '@/shared/api/ky'
6+
import { useQueryClient } from '@tanstack/react-query'
7+
8+
import { CreateModalHeader } from '@/features/create-post/ui/CreateModalHeader'
9+
import { PreviewPane } from '@/features/create-post/ui/PreviewPane'
10+
import { PostDetailsPane } from '@/features/create-post/ui/PostDetailsPane'
11+
import { useImageCarousel } from '@/features/create-post/model/hooks/useImageCarousel'
12+
import { useUpdatePostMutation } from '@/entities/post/model/hooks/useUpdatePostMutation'
13+
import type { PostData } from './PostDetail'
14+
15+
type EditPostModalProps = {
16+
open: boolean
17+
onOpenChange: (open: boolean) => void
18+
initialData: PostData
19+
onSuccess?: (updatedData: PostData) => void
20+
}
21+
22+
export function EditPostModal({
23+
open,
24+
onOpenChange,
25+
initialData,
26+
onSuccess,
27+
}: EditPostModalProps) {
28+
const [caption, setCaption] = useState(initialData.content)
29+
const [selectedAlbumId, setSelectedAlbumId] = useState<number>(
30+
initialData.albumId ?? -1
31+
)
32+
const [isSyncing, setIsSyncing] = useState(false)
33+
const [isSuccessState, setIsSuccessState] = useState(false)
34+
35+
const queryClient = useQueryClient()
36+
const updatePost = useUpdatePostMutation(initialData.id)
37+
const carousel = useImageCarousel(initialData.images.length)
38+
39+
const isDirty = useMemo(() => {
40+
return (
41+
caption !== initialData.content ||
42+
selectedAlbumId !== (initialData.albumId ?? -1)
43+
)
44+
}, [caption, selectedAlbumId, initialData])
45+
46+
const handleUpdate = useCallback(async () => {
47+
if (!isDirty) return
48+
setIsSyncing(true)
49+
50+
try {
51+
let updatedData: PostData = { ...initialData }
52+
53+
if (caption !== initialData.content) {
54+
const result = await updatePost.mutateAsync({
55+
content: caption,
56+
albumId: initialData.albumId ?? null,
57+
imageUrls: initialData.images.map((img) => img.url),
58+
})
59+
updatedData = { ...updatedData, content: result.content }
60+
}
61+
62+
if (selectedAlbumId !== (initialData.albumId ?? -1)) {
63+
if (selectedAlbumId === -1) {
64+
await instance.delete(
65+
`api/v1/albums/${initialData.albumId}/posts/${initialData.id}`
66+
)
67+
updatedData = { ...updatedData, albumId: null as unknown as number }
68+
} else {
69+
await instance.post(
70+
`api/v1/albums/${selectedAlbumId}/posts/${initialData.id}`
71+
)
72+
updatedData = { ...updatedData, albumId: selectedAlbumId }
73+
}
74+
}
75+
76+
setIsSuccessState(true)
77+
78+
setTimeout(() => {
79+
queryClient.invalidateQueries({ queryKey: ['posts'] })
80+
queryClient.invalidateQueries({ queryKey: ['albums'] })
81+
82+
onSuccess?.(updatedData)
83+
onOpenChange(false)
84+
}, 500)
85+
} catch {
86+
toast.error('정보 수정에 실패했습니다.')
87+
} finally {
88+
setIsSyncing(false)
89+
}
90+
}, [
91+
caption,
92+
selectedAlbumId,
93+
isDirty,
94+
updatePost,
95+
onOpenChange,
96+
initialData,
97+
onSuccess,
98+
queryClient,
99+
])
100+
101+
const activePreviewUrl = initialData.images[carousel.activeIndex]?.url
102+
103+
return (
104+
<Dialog open={open} onOpenChange={onOpenChange}>
105+
<DialogContent
106+
showCloseButton={false}
107+
overlayClassName="z-[110]"
108+
className="z-[120] flex flex-col gap-0 overflow-hidden rounded-4xl bg-white p-0 sm:w-[calc(80vh-51px+340px)] sm:max-w-[849px]"
109+
>
110+
<CreateModalHeader
111+
isUploaded={true}
112+
step="details"
113+
title="정보 수정"
114+
onBack={() => onOpenChange(false)}
115+
onShare={handleUpdate}
116+
isShareDisabled={!isDirty || isSyncing}
117+
isSharing={isSyncing}
118+
isShareSuccess={isSuccessState}
119+
/>
120+
<div className="h-px w-full bg-zinc-200" />
121+
122+
{isSyncing ? (
123+
<div className="flex aspect-square w-full items-center justify-center sm:h-[calc(80vh-51px)] sm:max-h-[509px]">
124+
<Loader2 className="size-16 animate-spin text-zinc-400" />
125+
</div>
126+
) : isSuccessState ? (
127+
<div className="flex aspect-square w-full flex-col items-center justify-center gap-4 sm:h-[calc(80vh-51px)] sm:max-h-[509px]">
128+
<CheckCircle2 className="size-24 text-green-500" />
129+
<p className="text-lg font-medium text-zinc-700">
130+
정보가 수정되었습니다.
131+
</p>
132+
</div>
133+
) : (
134+
<div className="flex min-h-0 flex-1 flex-col sm:flex-row">
135+
<div className="flex aspect-square w-full sm:h-[calc(80vh-51px)] sm:max-h-[509px] sm:w-[calc(80vh-51px)] sm:max-w-[509px] sm:flex-none">
136+
<PreviewPane
137+
activePreviewUrl={activePreviewUrl}
138+
filesCount={initialData.images.length}
139+
activeIndex={carousel.activeIndex}
140+
canGoPrev={carousel.canGoPrev}
141+
canGoNext={carousel.canGoNext}
142+
dots={carousel.dots}
143+
goPrev={carousel.goPrev}
144+
goNext={carousel.goNext}
145+
/>
146+
</div>
147+
148+
<div className="min-h-0 flex-1 sm:w-[340px] sm:shrink-0">
149+
<PostDetailsPane
150+
profileName={initialData.nickname}
151+
profileImageUrl={initialData.profileImageUrl}
152+
caption={caption}
153+
onCaptionChange={setCaption}
154+
selectedAlbumId={selectedAlbumId}
155+
onAlbumSelect={setSelectedAlbumId}
156+
/>
157+
</div>
158+
</div>
159+
)}
160+
</DialogContent>
161+
</Dialog>
162+
)
163+
}

src/components/post/PostDetail.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,7 @@ export default function PostDetail() {
5454
.json<{ data: PostData; isSuccess: boolean }>()
5555

5656
if (res.isSuccess) {
57-
setPostData(res.data)
57+
setPostData({ ...res.data })
5858
}
5959
} catch {
6060
console.error('Failed to fetch post')
@@ -105,7 +105,7 @@ export default function PostDetail() {
105105
}
106106

107107
const handlePostDataChange = (newData: PostData) => {
108-
setPostData(newData)
108+
setPostData({ ...newData })
109109
}
110110

111111
return (

src/components/post/PostInfoSection.tsx

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,9 @@ const PostInfoSection = forwardRef<PostInfoSectionRef, PostInfoSectionProps>(
5656
const commentInputRef = useRef<HTMLInputElement>(null)
5757

5858
useEffect(() => {
59-
setPostData(initialData)
59+
if (initialData) {
60+
setPostData({ ...initialData })
61+
}
6062
}, [initialData])
6163

6264
useImperativeHandle(ref, () => ({
@@ -294,6 +296,12 @@ const PostInfoSection = forwardRef<PostInfoSectionRef, PostInfoSectionProps>(
294296
}, 0)
295297
}
296298

299+
const handleUpdateSuccess = (updatedData: PostData) => {
300+
const clonedData = { ...updatedData }
301+
setPostData(clonedData)
302+
onDataChange?.(clonedData)
303+
}
304+
297305
const formattedFullDate = postData?.createdAt
298306
? new Date(postData.createdAt).toLocaleDateString('ko-KR', {
299307
year: 'numeric',
@@ -302,6 +310,12 @@ const PostInfoSection = forwardRef<PostInfoSectionRef, PostInfoSectionProps>(
302310
})
303311
: ''
304312

313+
const isEdited =
314+
postData?.updatedAt &&
315+
postData?.createdAt &&
316+
new Date(postData.updatedAt).getTime() >
317+
new Date(postData.createdAt).getTime() + 1000
318+
305319
return (
306320
<div className="flex h-full flex-col bg-white">
307321
<div className="flex shrink-0 items-center justify-between border-b border-gray-200 px-4 py-3">
@@ -368,7 +382,7 @@ const PostInfoSection = forwardRef<PostInfoSectionRef, PostInfoSectionProps>(
368382
<span className="whitespace-pre-wrap">{postData?.content}</span>
369383
<div className="mt-2 text-xs font-normal text-gray-500">
370384
{postData?.createdAt
371-
? formatRelativeTime(postData.createdAt)
385+
? `${formatRelativeTime(postData.createdAt)}${isEdited ? ' • 수정됨' : ''}`
372386
: ''}
373387
</div>
374388
</div>
@@ -411,6 +425,8 @@ const PostInfoSection = forwardRef<PostInfoSectionRef, PostInfoSectionProps>(
411425
nickname={postData?.nickname || ''}
412426
authorId={postData?.userId || 0}
413427
profileImageUrl={postData?.profileImageUrl || null}
428+
initialPostData={postData ?? undefined}
429+
onUpdateSuccess={handleUpdateSuccess}
414430
/>
415431
)}
416432
</div>

src/components/post/PostMenuModal.tsx

Lines changed: 33 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,17 +3,21 @@ import { useNavigate } from '@tanstack/react-router'
33
import ReportModal from './ReportModal'
44
import AccountInfoModal from './AccountInfoModal'
55
import EmbedModal from './EmbedModal'
6+
import { EditPostModal } from './EditPostModal'
67
import { useCurrentUserId } from '@/shared/auth/useCurrentUser'
78
import { useFollowing } from '@/features/follow-user/model/useFollowing'
89
import { useToggleFollow } from '@/features/follow-user/model/useToggleFollow'
910
import { instance } from '@/shared/api/ky'
11+
import type { PostData } from './PostDetail'
1012

1113
interface PostMenuModalProps {
1214
onClose: () => void
1315
nickname: string
1416
authorId: number
1517
postId: number
1618
profileImageUrl: string | null
19+
initialPostData?: PostData
20+
onUpdateSuccess?: (updatedData: PostData) => void
1721
}
1822

1923
export default function PostMenuModal({
@@ -22,9 +26,11 @@ export default function PostMenuModal({
2226
authorId,
2327
postId,
2428
profileImageUrl,
29+
initialPostData,
30+
onUpdateSuccess,
2531
}: PostMenuModalProps) {
2632
const [activeModal, setActiveModal] = useState<
27-
'menu' | 'report' | 'account' | 'embed' | 'delete_confirm'
33+
'menu' | 'report' | 'account' | 'embed' | 'delete_confirm' | 'edit'
2834
>('menu')
2935
const [showToast, setShowToast] = useState(false)
3036
const navigate = useNavigate()
@@ -68,14 +74,24 @@ export default function PostMenuModal({
6874

6975
if (response.isSuccess) {
7076
onClose()
71-
// 삭제 후 내 프로필 페이지나 홈으로 이동
7277
navigate({ to: `/${authorId}` })
7378
}
7479
} catch (err) {
7580
console.error('게시글 삭제 실패:', err)
7681
}
7782
}
7883

84+
const handleEditSuccess = (updatedData: PostData) => {
85+
if (onUpdateSuccess && initialPostData) {
86+
const finalData = {
87+
...initialPostData,
88+
...updatedData,
89+
}
90+
onUpdateSuccess(finalData)
91+
}
92+
onClose()
93+
}
94+
7995
if (activeModal === 'report') {
8096
return (
8197
<ReportModal
@@ -101,6 +117,17 @@ export default function PostMenuModal({
101117
return <EmbedModal onClose={onClose} postId={postId} nickname={nickname} />
102118
}
103119

120+
if (activeModal === 'edit' && initialPostData) {
121+
return (
122+
<EditPostModal
123+
open={true}
124+
onOpenChange={(open) => !open && onClose()}
125+
initialData={initialPostData}
126+
onSuccess={handleEditSuccess}
127+
/>
128+
)
129+
}
130+
104131
if (activeModal === 'delete_confirm') {
105132
return (
106133
<div
@@ -154,7 +181,10 @@ export default function PostMenuModal({
154181
>
155182
삭제
156183
</button>
157-
<button className="w-full border-b border-gray-200 py-3 active:bg-gray-100">
184+
<button
185+
onClick={() => setActiveModal('edit')}
186+
className="w-full border-b border-gray-200 py-3 active:bg-gray-100"
187+
>
158188
수정
159189
</button>
160190
</>
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
import { useMutation, useQueryClient } from '@tanstack/react-query'
2+
import { instance } from '@/shared/api/ky'
3+
import type { PostData } from '@/components/post/PostDetail'
4+
5+
export function useUpdatePostMutation(postId: number) {
6+
const queryClient = useQueryClient()
7+
8+
return useMutation({
9+
mutationFn: async (payload: {
10+
content: string
11+
albumId: number | null
12+
imageUrls: string[]
13+
}) => {
14+
const response = await instance
15+
.put(`api/v1/posts/${postId}`, { json: payload })
16+
.json<{ data: PostData; isSuccess: boolean }>()
17+
return response.data
18+
},
19+
onSuccess: (updatedData) => {
20+
queryClient.setQueryData(['post', postId], updatedData)
21+
queryClient.invalidateQueries({ queryKey: ['posts'] })
22+
},
23+
})
24+
}

0 commit comments

Comments
 (0)