@farmzone/fz-template-react 1.0.2 → 1.0.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/template/.env.example +5 -5
- package/template/package.json +55 -55
- package/template/pnpm-lock.yaml +4214 -0
- package/template/public/mockServiceWorker.js +349 -349
- package/template/src/app/api/api.ts +178 -178
- package/template/src/app/api/queries.ts +321 -321
- package/template/src/app/api/queryKey.ts +7 -7
- package/template/src/app/api/token.ts +7 -7
- package/template/src/app/layout/Layout.tsx +33 -33
- package/template/src/app/layout/ListContents.tsx +9 -9
- package/template/src/app/layout/ListHeader.tsx +41 -41
- package/template/src/app/layout/MultiTabNav.tsx +101 -101
- package/template/src/app/layout/Sidebar.tsx +33 -33
- package/template/src/app/layout/UserInfo.tsx +94 -94
- package/template/src/app/layout/menu.ts +4 -1
- package/template/src/app/layout/tabSwitchStore.ts +11 -11
- package/template/src/app/router/Router.tsx +56 -54
- package/template/src/app/store/index.ts +26 -26
- package/template/src/index.tsx +21 -21
- package/template/src/mocks/browser.ts +17 -17
- package/template/src/mocks/handlers.ts +43 -43
- package/template/src/mocks/scenarios.ts +57 -57
- package/template/src/pages/dashboard/index.tsx +541 -541
- package/template/src/pages/error/Error.tsx +29 -29
- package/template/src/pages/error/NotFound.tsx +27 -27
- package/template/src/pages/login/index.tsx +317 -317
- package/template/src/pages/post/PostFormModal.tsx +128 -128
- package/template/src/pages/post/detail/index.tsx +548 -548
- package/template/src/pages/post/index.tsx +267 -267
- package/template/src/pages/sample/SampleFormModal.tsx +77 -77
- package/template/src/pages/sample/detail/index.tsx +424 -424
- package/template/src/pages/sample/index.tsx +269 -269
- package/template/src/pages/sample/modal/index.tsx +253 -0
- package/template/src/pages/system/log/index.tsx +173 -173
- package/template/src/pages/user/config/columns.tsx +109 -109
- package/template/src/pages/user/config/schema.ts +54 -54
- package/template/src/pages/user/index.tsx +641 -641
- package/template/src/shared/components/CommentInput.tsx +243 -243
- package/template/src/shared/config/text.ts +27 -27
- package/template/src/shared/utils/format.ts +11 -11
- package/template/src/types/auth.ts +10 -10
- package/template/src/types/comment.ts +33 -33
- package/template/src/types/common.ts +19 -19
- package/template/src/types/dashboard.ts +53 -53
- package/template/src/types/index.ts +16 -16
- package/template/src/types/log.ts +21 -21
- package/template/src/types/post.ts +32 -32
- package/template/src/types/sample.ts +28 -28
- package/template/src/types/user.ts +51 -51
- package/template/src/vite-env.d.ts +10 -10
|
@@ -1,548 +1,548 @@
|
|
|
1
|
-
import { useEffect, useState } from "react";
|
|
2
|
-
import { useNavigate, useParams } from "react-router";
|
|
3
|
-
import { useQueryClient } from "@tanstack/react-query";
|
|
4
|
-
|
|
5
|
-
import { useUserStore } from "@/app/store";
|
|
6
|
-
import {
|
|
7
|
-
Badge,
|
|
8
|
-
Button,
|
|
9
|
-
confirmModal,
|
|
10
|
-
FilePreviewViewer,
|
|
11
|
-
getFileLabel,
|
|
12
|
-
toast,
|
|
13
|
-
useFilePreviewViewer,
|
|
14
|
-
} from "@farmzone/fz-react-ui";
|
|
15
|
-
import { CornerDownRight, Pencil, Trash2 } from "lucide-react";
|
|
16
|
-
|
|
17
|
-
import {
|
|
18
|
-
useDeleteComment,
|
|
19
|
-
useDeletePost,
|
|
20
|
-
useGetComments,
|
|
21
|
-
useGetPost,
|
|
22
|
-
usePutComment,
|
|
23
|
-
} from "@/app/api/queries";
|
|
24
|
-
import type { Comment, FileResponse } from "@/types";
|
|
25
|
-
import { apiFileInstance, apiFormDataInstance } from "@/app/api/api";
|
|
26
|
-
import { POST_QUERY_KEY } from "@/app/api/queryKey";
|
|
27
|
-
import ListHeader from "@/app/layout/ListHeader";
|
|
28
|
-
import CommentInput from "@/shared/components/CommentInput";
|
|
29
|
-
import { COMMON_MESSAGES } from "@/shared/config/text";
|
|
30
|
-
import { formatDateTime } from "@/shared/utils/format";
|
|
31
|
-
import { PostFormModal } from "../PostFormModal";
|
|
32
|
-
import type { PostFormData } from "../PostFormModal";
|
|
33
|
-
import FilePreviewCard from "@/shared/components/FilePreviewCard";
|
|
34
|
-
|
|
35
|
-
function countComments(list: Array<Comment>): number {
|
|
36
|
-
return list.reduce((sum, c) => {
|
|
37
|
-
const depth2 = c.replies ?? [];
|
|
38
|
-
const depth3Count = depth2.reduce((s, r) => s + (r.replies?.length ?? 0), 0);
|
|
39
|
-
return sum + 1 + depth2.length + depth3Count;
|
|
40
|
-
}, 0);
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
function toPreviewFileType(mimeType: string): "image" | "pdf" | "unsupported" {
|
|
44
|
-
if (mimeType?.startsWith("image/")) return "image";
|
|
45
|
-
if (mimeType === "application/pdf") return "pdf";
|
|
46
|
-
return "unsupported";
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
export default function PostDetailPage() {
|
|
50
|
-
const { id } = useParams<{ id: string }>();
|
|
51
|
-
const postId = Number(id);
|
|
52
|
-
const navigate = useNavigate();
|
|
53
|
-
const queryClient = useQueryClient();
|
|
54
|
-
const currentUser = useUserStore((s) => s.user);
|
|
55
|
-
|
|
56
|
-
const [isEditOpen, setIsEditOpen] = useState(false);
|
|
57
|
-
const [replyingToId, setReplyingToId] = useState<number | null>(null);
|
|
58
|
-
const [editingCommentId, setEditingCommentId] = useState<number | null>(null);
|
|
59
|
-
|
|
60
|
-
// 상세 화면 파일 미리보기용 blob URL 상태
|
|
61
|
-
const [blobUrls, setBlobUrls] = useState<Array<string>>([]);
|
|
62
|
-
const [previewFileNames, setPreviewFileNames] = useState<Array<string>>([]);
|
|
63
|
-
const [previewFileTypes, setPreviewFileTypes] = useState<Array<"image" | "pdf" | "unsupported">>([]);
|
|
64
|
-
|
|
65
|
-
// 수정 모달 파일 상태
|
|
66
|
-
const [pendingFiles, setPendingFiles] = useState<Array<File>>([]);
|
|
67
|
-
const [existingFiles, setExistingFiles] = useState<Array<FileResponse>>([]);
|
|
68
|
-
const [deleteFileIds, setDeleteFileIds] = useState<Array<number>>([]);
|
|
69
|
-
const [isEditUploading, setIsEditUploading] = useState(false);
|
|
70
|
-
|
|
71
|
-
const { data: post, isLoading } = useGetPost(postId);
|
|
72
|
-
const { data: comments = [] } = useGetComments("POST", postId);
|
|
73
|
-
const { mutateAsync: deletePost } = useDeletePost();
|
|
74
|
-
const { mutateAsync: deleteComment } = useDeleteComment();
|
|
75
|
-
const { mutateAsync: putComment, isPending: isCommentUpdating } = usePutComment();
|
|
76
|
-
|
|
77
|
-
const postFiles = post?.files ?? [];
|
|
78
|
-
const topComments = comments.filter((c) => c.parentCommentId === null);
|
|
79
|
-
const totalCommentCount = countComments(topComments);
|
|
80
|
-
|
|
81
|
-
// 첨부파일 blob URL 생성 (FormFile.tsx의 getFiles 패턴)
|
|
82
|
-
useEffect(() => {
|
|
83
|
-
if (postFiles.length === 0) {
|
|
84
|
-
setBlobUrls([]);
|
|
85
|
-
setPreviewFileNames([]);
|
|
86
|
-
setPreviewFileTypes([]);
|
|
87
|
-
return;
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
let cancelled = false;
|
|
91
|
-
const createdUrls: Array<string> = [];
|
|
92
|
-
|
|
93
|
-
(async () => {
|
|
94
|
-
const results = await Promise.all(
|
|
95
|
-
postFiles.map(async (file) => {
|
|
96
|
-
try {
|
|
97
|
-
const { data: blob } = await apiFileInstance.get(file.filePath, {
|
|
98
|
-
responseType: "blob",
|
|
99
|
-
});
|
|
100
|
-
const url = URL.createObjectURL(blob);
|
|
101
|
-
createdUrls.push(url);
|
|
102
|
-
return { url, name: file.fileName, type: toPreviewFileType(file.fileType) };
|
|
103
|
-
} catch {
|
|
104
|
-
return { url: "", name: file.fileName, type: "unsupported" as const };
|
|
105
|
-
}
|
|
106
|
-
}),
|
|
107
|
-
);
|
|
108
|
-
|
|
109
|
-
if (!cancelled) {
|
|
110
|
-
setBlobUrls(results.map((r) => r.url));
|
|
111
|
-
setPreviewFileNames(results.map((r) => r.name));
|
|
112
|
-
setPreviewFileTypes(results.map((r) => r.type));
|
|
113
|
-
}
|
|
114
|
-
})();
|
|
115
|
-
|
|
116
|
-
return () => {
|
|
117
|
-
cancelled = true;
|
|
118
|
-
createdUrls.forEach((url) => url && URL.revokeObjectURL(url));
|
|
119
|
-
};
|
|
120
|
-
// postFiles 참조가 바뀔 때만 재실행 (길이 + 첫 id로 비교)
|
|
121
|
-
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
122
|
-
}, [postFiles.length, postFiles[0]?.id]);
|
|
123
|
-
|
|
124
|
-
const filePreview = useFilePreviewViewer(blobUrls);
|
|
125
|
-
|
|
126
|
-
const openEditModal = () => {
|
|
127
|
-
setPendingFiles([]);
|
|
128
|
-
setExistingFiles(post?.files ?? []);
|
|
129
|
-
setDeleteFileIds([]);
|
|
130
|
-
setIsEditOpen(true);
|
|
131
|
-
};
|
|
132
|
-
|
|
133
|
-
const closeEditModal = () => {
|
|
134
|
-
setIsEditOpen(false);
|
|
135
|
-
setPendingFiles([]);
|
|
136
|
-
setExistingFiles([]);
|
|
137
|
-
setDeleteFileIds([]);
|
|
138
|
-
};
|
|
139
|
-
|
|
140
|
-
const handleDeleteExistingFile = (fileId: number) => {
|
|
141
|
-
setExistingFiles((prev) => prev.filter((f) => f.id !== fileId));
|
|
142
|
-
setDeleteFileIds((prev) => [...prev, fileId]);
|
|
143
|
-
};
|
|
144
|
-
|
|
145
|
-
const handleEdit = async (data: PostFormData) => {
|
|
146
|
-
setIsEditUploading(true);
|
|
147
|
-
try {
|
|
148
|
-
const request = {
|
|
149
|
-
title: data.title,
|
|
150
|
-
content: data.content,
|
|
151
|
-
noticeCategory: data.noticeCategory,
|
|
152
|
-
visible: data.visible,
|
|
153
|
-
writer: currentUser?.userId ?? "",
|
|
154
|
-
deleteFileIds,
|
|
155
|
-
};
|
|
156
|
-
const formData = new FormData();
|
|
157
|
-
formData.append("request", JSON.stringify(request));
|
|
158
|
-
pendingFiles.forEach((f) => formData.append("files", f));
|
|
159
|
-
await apiFormDataInstance.put(`/posts/${postId}`, formData);
|
|
160
|
-
toast.success(COMMON_MESSAGES.UPDATE_SUCCESS);
|
|
161
|
-
void queryClient.invalidateQueries({ queryKey: [POST_QUERY_KEY] });
|
|
162
|
-
closeEditModal();
|
|
163
|
-
} finally {
|
|
164
|
-
setIsEditUploading(false);
|
|
165
|
-
}
|
|
166
|
-
};
|
|
167
|
-
|
|
168
|
-
const handleDelete = () => {
|
|
169
|
-
if (!post) return;
|
|
170
|
-
confirmModal({
|
|
171
|
-
content: `"${post.title}" 게시글을 삭제하시겠습니까?`,
|
|
172
|
-
onOk: async () => {
|
|
173
|
-
await deletePost(postId);
|
|
174
|
-
navigate("/post");
|
|
175
|
-
},
|
|
176
|
-
onCancel: () => {},
|
|
177
|
-
className: "max-w-100",
|
|
178
|
-
});
|
|
179
|
-
};
|
|
180
|
-
|
|
181
|
-
const isCommentOwner = (commentUserId: number | null) =>
|
|
182
|
-
commentUserId !== null && String(commentUserId) === String(currentUser?.id);
|
|
183
|
-
|
|
184
|
-
const handleCommentEditStart = (comment: Comment) => {
|
|
185
|
-
if (!isCommentOwner(comment.userId)) {
|
|
186
|
-
toast.error("본인의 댓글만 수정할 수 있습니다.");
|
|
187
|
-
return;
|
|
188
|
-
}
|
|
189
|
-
setEditingCommentId(comment.id);
|
|
190
|
-
};
|
|
191
|
-
|
|
192
|
-
const handleCommentDelete = (commentId: number, commentUserId: number | null) => {
|
|
193
|
-
if (!isCommentOwner(commentUserId)) {
|
|
194
|
-
toast.error("본인의 댓글만 삭제할 수 있습니다.");
|
|
195
|
-
return;
|
|
196
|
-
}
|
|
197
|
-
confirmModal({
|
|
198
|
-
content: "댓글을 삭제하시겠습니까?",
|
|
199
|
-
onOk: async () => {
|
|
200
|
-
await deleteComment(commentId);
|
|
201
|
-
},
|
|
202
|
-
onCancel: () => {},
|
|
203
|
-
className: "max-w-100",
|
|
204
|
-
});
|
|
205
|
-
};
|
|
206
|
-
|
|
207
|
-
const handleCommentSave = async (commentId: number, content: string, mentionUserIds: Array<number>) => {
|
|
208
|
-
await putComment({
|
|
209
|
-
commentId,
|
|
210
|
-
data: { content, mentionUserIds: mentionUserIds.length > 0 ? mentionUserIds : undefined },
|
|
211
|
-
});
|
|
212
|
-
setEditingCommentId(null);
|
|
213
|
-
};
|
|
214
|
-
|
|
215
|
-
const editDefaultValues: PostFormData | undefined = post
|
|
216
|
-
? {
|
|
217
|
-
title: post.title,
|
|
218
|
-
content: post.content,
|
|
219
|
-
noticeCategory: post.noticeCategory,
|
|
220
|
-
visible: post.visible ?? true,
|
|
221
|
-
}
|
|
222
|
-
: undefined;
|
|
223
|
-
|
|
224
|
-
const renderCommentActions = (comment: Comment) => {
|
|
225
|
-
if (!isCommentOwner(comment.userId)) return null;
|
|
226
|
-
return (
|
|
227
|
-
<div className="flex shrink-0 items-center gap-0.5">
|
|
228
|
-
<Button
|
|
229
|
-
type="button"
|
|
230
|
-
variant="ghost"
|
|
231
|
-
size="icon-sm"
|
|
232
|
-
onClick={() => handleCommentEditStart(comment)}
|
|
233
|
-
className="cursor-pointer rounded p-1 text-gray-400 hover:bg-gray-100 hover:text-blue-500"
|
|
234
|
-
aria-label="댓글 수정"
|
|
235
|
-
>
|
|
236
|
-
<Pencil size={14} />
|
|
237
|
-
</Button>
|
|
238
|
-
<Button
|
|
239
|
-
type="button"
|
|
240
|
-
variant="ghost"
|
|
241
|
-
size="icon-sm"
|
|
242
|
-
onClick={() => handleCommentDelete(comment.id, comment.userId)}
|
|
243
|
-
className="cursor-pointer rounded p-1 text-gray-400 hover:bg-gray-100 hover:text-red-500"
|
|
244
|
-
aria-label="댓글 삭제"
|
|
245
|
-
>
|
|
246
|
-
<Trash2 size={14} />
|
|
247
|
-
</Button>
|
|
248
|
-
</div>
|
|
249
|
-
);
|
|
250
|
-
};
|
|
251
|
-
|
|
252
|
-
return (
|
|
253
|
-
<div className="p-6">
|
|
254
|
-
<ListHeader
|
|
255
|
-
title="게시글 상세"
|
|
256
|
-
rightArea={
|
|
257
|
-
<div className="flex items-center gap-2">
|
|
258
|
-
<Button variant="outline" onClick={() => navigate("/post")}>
|
|
259
|
-
목록
|
|
260
|
-
</Button>
|
|
261
|
-
{post && (
|
|
262
|
-
<>
|
|
263
|
-
<Button variant="save" onClick={openEditModal}>
|
|
264
|
-
수정
|
|
265
|
-
</Button>
|
|
266
|
-
<Button variant="delete" onClick={handleDelete}>
|
|
267
|
-
삭제
|
|
268
|
-
</Button>
|
|
269
|
-
</>
|
|
270
|
-
)}
|
|
271
|
-
</div>
|
|
272
|
-
}
|
|
273
|
-
/>
|
|
274
|
-
|
|
275
|
-
<div className="mt-6 space-y-6">
|
|
276
|
-
{/* 게시글 - 공지사항 스타일 */}
|
|
277
|
-
<div className="overflow-hidden rounded-xl border border-gray-200 bg-white">
|
|
278
|
-
{isLoading ? (
|
|
279
|
-
<div className="p-8 text-center text-sm text-gray-400">불러오는 중...</div>
|
|
280
|
-
) : post ? (
|
|
281
|
-
<>
|
|
282
|
-
{/* 제목 + 메타 정보 */}
|
|
283
|
-
<div className="border-b border-gray-100 bg-gray-50/50 px-6 py-5">
|
|
284
|
-
<h2 className="mb-3 text-xl font-bold leading-tight text-gray-900">{post.title}</h2>
|
|
285
|
-
<div className="flex items-center gap-3 text-xs text-gray-500">
|
|
286
|
-
<Badge
|
|
287
|
-
text={post.visible ? "노출" : "미노출"}
|
|
288
|
-
className={`scale-90 ${post.visible ? "bg-green-100 text-green-700 border-green-100" : "bg-gray-100 text-gray-500 border-gray-200"}`}
|
|
289
|
-
/>
|
|
290
|
-
<span className="text-gray-300">|</span>
|
|
291
|
-
<span>작성자: {post.writer}</span>
|
|
292
|
-
<span className="text-gray-300">|</span>
|
|
293
|
-
<span>등록일: {formatDateTime(post.createdAt)}</span>
|
|
294
|
-
<span className="text-gray-300">|</span>
|
|
295
|
-
<span>수정일: {formatDateTime(post.updatedAt)}</span>
|
|
296
|
-
</div>
|
|
297
|
-
</div>
|
|
298
|
-
|
|
299
|
-
{/* 본문 */}
|
|
300
|
-
<div className="min-h-44 px-6 py-6">
|
|
301
|
-
<p className="whitespace-pre-wrap text-sm leading-7 text-gray-800">{post.content}</p>
|
|
302
|
-
</div>
|
|
303
|
-
|
|
304
|
-
{/* TODO fix issues */}
|
|
305
|
-
{/* 첨부파일 미리보기 뷰어 */}
|
|
306
|
-
{postFiles.length > 0 && (
|
|
307
|
-
<>
|
|
308
|
-
<div className="grid grid-cols-2 gap-2 w-full">
|
|
309
|
-
{postFiles
|
|
310
|
-
.map((postFile) => postFile.filePath)
|
|
311
|
-
.map((img, idx) => (
|
|
312
|
-
<FilePreviewCard
|
|
313
|
-
key={idx}
|
|
314
|
-
src={img}
|
|
315
|
-
fileName={getFileLabel(img, idx)}
|
|
316
|
-
onClick={() => filePreview.open(idx)}
|
|
317
|
-
/>
|
|
318
|
-
))}
|
|
319
|
-
</div>
|
|
320
|
-
<FilePreviewViewer
|
|
321
|
-
{...filePreview.viewerProps}
|
|
322
|
-
fileNames={previewFileNames}
|
|
323
|
-
fileTypes={previewFileTypes}
|
|
324
|
-
/>
|
|
325
|
-
</>
|
|
326
|
-
)}
|
|
327
|
-
</>
|
|
328
|
-
) : (
|
|
329
|
-
<div className="p-8 text-center text-sm text-gray-400">게시글을 찾을 수 없습니다.</div>
|
|
330
|
-
)}
|
|
331
|
-
</div>
|
|
332
|
-
|
|
333
|
-
{/* 댓글 섹션 */}
|
|
334
|
-
<div className="rounded-xl border border-gray-200 bg-white">
|
|
335
|
-
<div className="border-b border-gray-100 px-5 py-3">
|
|
336
|
-
<h3 className="text-sm font-semibold text-gray-700">댓글 {totalCommentCount}개</h3>
|
|
337
|
-
</div>
|
|
338
|
-
|
|
339
|
-
{topComments.length > 0 ? (
|
|
340
|
-
<ul className="divide-y divide-gray-100">
|
|
341
|
-
{topComments.map((comment) => {
|
|
342
|
-
const depth2Replies = comment.replies ?? [];
|
|
343
|
-
const isReplying = replyingToId === comment.id;
|
|
344
|
-
const isEditing = editingCommentId === comment.id;
|
|
345
|
-
|
|
346
|
-
return (
|
|
347
|
-
<li key={comment.id}>
|
|
348
|
-
{/* depth 1: 원댓글 */}
|
|
349
|
-
<div className="flex items-start gap-3 px-5 py-4">
|
|
350
|
-
<div className="flex-1 space-y-1">
|
|
351
|
-
<div className="flex items-center gap-2">
|
|
352
|
-
<span className="text-sm font-semibold text-gray-800">
|
|
353
|
-
{comment.userName || "알수없음"}
|
|
354
|
-
</span>
|
|
355
|
-
<span className="text-xs text-gray-400">{formatDateTime(comment.createdAt)}</span>
|
|
356
|
-
</div>
|
|
357
|
-
{isEditing ? (
|
|
358
|
-
<CommentInput
|
|
359
|
-
initialContent={comment.content}
|
|
360
|
-
initialMentionUserIds={comment.mentionUserIds ?? []}
|
|
361
|
-
onSave={(content, mentionUserIds) =>
|
|
362
|
-
handleCommentSave(comment.id, content, mentionUserIds)
|
|
363
|
-
}
|
|
364
|
-
isSaving={isCommentUpdating}
|
|
365
|
-
saveLabel="저장"
|
|
366
|
-
onCancel={() => setEditingCommentId(null)}
|
|
367
|
-
/>
|
|
368
|
-
) : (
|
|
369
|
-
<>
|
|
370
|
-
<p className="whitespace-pre-wrap text-sm text-gray-700">{comment.content}</p>
|
|
371
|
-
{!comment.deletedAt && (
|
|
372
|
-
<button
|
|
373
|
-
type="button"
|
|
374
|
-
onClick={() => setReplyingToId(isReplying ? null : comment.id)}
|
|
375
|
-
className="mt-0.5 flex items-center gap-1 text-xs text-gray-400 transition-colors hover:text-blue-500"
|
|
376
|
-
>
|
|
377
|
-
<CornerDownRight size={11} />
|
|
378
|
-
{isReplying ? "취소" : "답글"}
|
|
379
|
-
</button>
|
|
380
|
-
)}
|
|
381
|
-
</>
|
|
382
|
-
)}
|
|
383
|
-
</div>
|
|
384
|
-
{!isEditing && renderCommentActions(comment)}
|
|
385
|
-
</div>
|
|
386
|
-
|
|
387
|
-
{/* depth 2: 대댓글 목록 */}
|
|
388
|
-
{depth2Replies.length > 0 && (
|
|
389
|
-
<ul className="border-t border-gray-50 bg-gray-50/60">
|
|
390
|
-
{depth2Replies.map((reply) => {
|
|
391
|
-
const depth3Replies = reply.replies ?? [];
|
|
392
|
-
const isReplyingToReply = replyingToId === reply.id;
|
|
393
|
-
const isEditingReply = editingCommentId === reply.id;
|
|
394
|
-
|
|
395
|
-
return (
|
|
396
|
-
<li key={reply.id} className="border-b border-gray-100 last:border-0">
|
|
397
|
-
<div className="flex items-start gap-2.5 py-3 pl-10 pr-5">
|
|
398
|
-
<CornerDownRight size={13} className="mt-0.5 shrink-0 text-gray-300" />
|
|
399
|
-
<div className="flex-1 space-y-0.5">
|
|
400
|
-
<div className="flex items-center gap-2">
|
|
401
|
-
<span className="text-sm font-semibold text-gray-800">
|
|
402
|
-
{reply.userName}
|
|
403
|
-
</span>
|
|
404
|
-
<span className="text-xs text-gray-400">
|
|
405
|
-
{formatDateTime(reply.createdAt)}
|
|
406
|
-
</span>
|
|
407
|
-
</div>
|
|
408
|
-
{isEditingReply ? (
|
|
409
|
-
<CommentInput
|
|
410
|
-
initialContent={reply.content}
|
|
411
|
-
initialMentionUserIds={reply.mentionUserIds ?? []}
|
|
412
|
-
onSave={(content, mentionUserIds) =>
|
|
413
|
-
handleCommentSave(reply.id, content, mentionUserIds)
|
|
414
|
-
}
|
|
415
|
-
isSaving={isCommentUpdating}
|
|
416
|
-
saveLabel="저장"
|
|
417
|
-
onCancel={() => setEditingCommentId(null)}
|
|
418
|
-
/>
|
|
419
|
-
) : (
|
|
420
|
-
<>
|
|
421
|
-
<p className="whitespace-pre-wrap text-sm text-gray-600">
|
|
422
|
-
{reply.content}
|
|
423
|
-
</p>
|
|
424
|
-
{reply.content !== "삭제된 댓글입니다." && (
|
|
425
|
-
<button
|
|
426
|
-
type="button"
|
|
427
|
-
onClick={() => setReplyingToId(isReplyingToReply ? null : reply.id)}
|
|
428
|
-
className="mt-0.5 flex items-center gap-1 text-xs text-gray-400 transition-colors hover:text-blue-500"
|
|
429
|
-
>
|
|
430
|
-
<CornerDownRight size={11} />
|
|
431
|
-
{isReplyingToReply ? "취소" : "답글"}
|
|
432
|
-
</button>
|
|
433
|
-
)}
|
|
434
|
-
</>
|
|
435
|
-
)}
|
|
436
|
-
</div>
|
|
437
|
-
{!isEditingReply && renderCommentActions(reply)}
|
|
438
|
-
</div>
|
|
439
|
-
|
|
440
|
-
{/* depth 3: 대대댓글 목록 (답글 버튼 없음) */}
|
|
441
|
-
{depth3Replies.length > 0 && (
|
|
442
|
-
<ul className="bg-gray-100/50">
|
|
443
|
-
{depth3Replies.map((deepReply) => {
|
|
444
|
-
const isEditingDeepReply = editingCommentId === deepReply.id;
|
|
445
|
-
return (
|
|
446
|
-
<li
|
|
447
|
-
key={deepReply.id}
|
|
448
|
-
className="flex items-start gap-2.5 border-b border-gray-100 py-3 pl-16 pr-5 last:border-0"
|
|
449
|
-
>
|
|
450
|
-
<CornerDownRight
|
|
451
|
-
size={13}
|
|
452
|
-
className="mt-0.5 shrink-0 text-gray-200"
|
|
453
|
-
/>
|
|
454
|
-
<div className="flex-1 space-y-0.5">
|
|
455
|
-
<div className="flex items-center gap-2">
|
|
456
|
-
<span className="text-sm font-semibold text-gray-800">
|
|
457
|
-
{deepReply.userName}
|
|
458
|
-
</span>
|
|
459
|
-
<span className="text-xs text-gray-400">
|
|
460
|
-
{formatDateTime(deepReply.createdAt)}
|
|
461
|
-
</span>
|
|
462
|
-
</div>
|
|
463
|
-
{isEditingDeepReply ? (
|
|
464
|
-
<CommentInput
|
|
465
|
-
initialContent={deepReply.content}
|
|
466
|
-
initialMentionUserIds={deepReply.mentionUserIds ?? []}
|
|
467
|
-
onSave={(content, mentionUserIds) =>
|
|
468
|
-
handleCommentSave(deepReply.id, content, mentionUserIds)
|
|
469
|
-
}
|
|
470
|
-
isSaving={isCommentUpdating}
|
|
471
|
-
saveLabel="저장"
|
|
472
|
-
onCancel={() => setEditingCommentId(null)}
|
|
473
|
-
/>
|
|
474
|
-
) : (
|
|
475
|
-
<p className="whitespace-pre-wrap text-sm text-gray-600">
|
|
476
|
-
{deepReply.content}
|
|
477
|
-
</p>
|
|
478
|
-
)}
|
|
479
|
-
</div>
|
|
480
|
-
{!isEditingDeepReply && renderCommentActions(deepReply)}
|
|
481
|
-
</li>
|
|
482
|
-
);
|
|
483
|
-
})}
|
|
484
|
-
</ul>
|
|
485
|
-
)}
|
|
486
|
-
|
|
487
|
-
{/* depth 2 답글 입력창 */}
|
|
488
|
-
{isReplyingToReply && !isEditingReply && (
|
|
489
|
-
<div className="border-t border-blue-50 bg-blue-50/30 py-3 pl-10 pr-5">
|
|
490
|
-
<CommentInput
|
|
491
|
-
targetType="POST"
|
|
492
|
-
targetId={postId}
|
|
493
|
-
parentCommentId={reply.id}
|
|
494
|
-
placeholder="답글을 입력하세요. @를 입력하면 사용자를 멘션할 수 있습니다."
|
|
495
|
-
onSuccess={() => setReplyingToId(null)}
|
|
496
|
-
/>
|
|
497
|
-
</div>
|
|
498
|
-
)}
|
|
499
|
-
</li>
|
|
500
|
-
);
|
|
501
|
-
})}
|
|
502
|
-
</ul>
|
|
503
|
-
)}
|
|
504
|
-
|
|
505
|
-
{/* depth 1 답글 입력창 */}
|
|
506
|
-
{isReplying && !isEditing && (
|
|
507
|
-
<div className="border-t border-blue-50 bg-blue-50/30 py-3 pl-10 pr-5">
|
|
508
|
-
<CommentInput
|
|
509
|
-
targetType="POST"
|
|
510
|
-
targetId={postId}
|
|
511
|
-
parentCommentId={comment.id}
|
|
512
|
-
placeholder="답글을 입력하세요. @를 입력하면 사용자를 멘션할 수 있습니다."
|
|
513
|
-
onSuccess={() => setReplyingToId(null)}
|
|
514
|
-
/>
|
|
515
|
-
</div>
|
|
516
|
-
)}
|
|
517
|
-
</li>
|
|
518
|
-
);
|
|
519
|
-
})}
|
|
520
|
-
</ul>
|
|
521
|
-
) : (
|
|
522
|
-
<div className="px-5 py-6 text-center text-sm text-gray-400">등록된 댓글이 없습니다.</div>
|
|
523
|
-
)}
|
|
524
|
-
|
|
525
|
-
{/* 최상위 댓글 입력창 */}
|
|
526
|
-
<div className="border-t border-gray-100 px-5 py-4">
|
|
527
|
-
<CommentInput targetType="POST" targetId={postId} parentCommentId={null} />
|
|
528
|
-
</div>
|
|
529
|
-
</div>
|
|
530
|
-
</div>
|
|
531
|
-
|
|
532
|
-
{/* 수정 모달 */}
|
|
533
|
-
<PostFormModal
|
|
534
|
-
mode="edit"
|
|
535
|
-
isOpen={isEditOpen}
|
|
536
|
-
onClose={closeEditModal}
|
|
537
|
-
defaultValues={editDefaultValues}
|
|
538
|
-
onSubmit={handleEdit}
|
|
539
|
-
isPending={isEditUploading}
|
|
540
|
-
pendingFiles={pendingFiles}
|
|
541
|
-
onPendingFilesChange={setPendingFiles}
|
|
542
|
-
existingFiles={existingFiles}
|
|
543
|
-
onDeleteExistingFile={handleDeleteExistingFile}
|
|
544
|
-
formKey={postId}
|
|
545
|
-
/>
|
|
546
|
-
</div>
|
|
547
|
-
);
|
|
548
|
-
}
|
|
1
|
+
import { useEffect, useState } from "react";
|
|
2
|
+
import { useNavigate, useParams } from "react-router";
|
|
3
|
+
import { useQueryClient } from "@tanstack/react-query";
|
|
4
|
+
|
|
5
|
+
import { useUserStore } from "@/app/store";
|
|
6
|
+
import {
|
|
7
|
+
Badge,
|
|
8
|
+
Button,
|
|
9
|
+
confirmModal,
|
|
10
|
+
FilePreviewViewer,
|
|
11
|
+
getFileLabel,
|
|
12
|
+
toast,
|
|
13
|
+
useFilePreviewViewer,
|
|
14
|
+
} from "@farmzone/fz-react-ui";
|
|
15
|
+
import { CornerDownRight, Pencil, Trash2 } from "lucide-react";
|
|
16
|
+
|
|
17
|
+
import {
|
|
18
|
+
useDeleteComment,
|
|
19
|
+
useDeletePost,
|
|
20
|
+
useGetComments,
|
|
21
|
+
useGetPost,
|
|
22
|
+
usePutComment,
|
|
23
|
+
} from "@/app/api/queries";
|
|
24
|
+
import type { Comment, FileResponse } from "@/types";
|
|
25
|
+
import { apiFileInstance, apiFormDataInstance } from "@/app/api/api";
|
|
26
|
+
import { POST_QUERY_KEY } from "@/app/api/queryKey";
|
|
27
|
+
import ListHeader from "@/app/layout/ListHeader";
|
|
28
|
+
import CommentInput from "@/shared/components/CommentInput";
|
|
29
|
+
import { COMMON_MESSAGES } from "@/shared/config/text";
|
|
30
|
+
import { formatDateTime } from "@/shared/utils/format";
|
|
31
|
+
import { PostFormModal } from "../PostFormModal";
|
|
32
|
+
import type { PostFormData } from "../PostFormModal";
|
|
33
|
+
import FilePreviewCard from "@/shared/components/FilePreviewCard";
|
|
34
|
+
|
|
35
|
+
function countComments(list: Array<Comment>): number {
|
|
36
|
+
return list.reduce((sum, c) => {
|
|
37
|
+
const depth2 = c.replies ?? [];
|
|
38
|
+
const depth3Count = depth2.reduce((s, r) => s + (r.replies?.length ?? 0), 0);
|
|
39
|
+
return sum + 1 + depth2.length + depth3Count;
|
|
40
|
+
}, 0);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function toPreviewFileType(mimeType: string): "image" | "pdf" | "unsupported" {
|
|
44
|
+
if (mimeType?.startsWith("image/")) return "image";
|
|
45
|
+
if (mimeType === "application/pdf") return "pdf";
|
|
46
|
+
return "unsupported";
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export default function PostDetailPage() {
|
|
50
|
+
const { id } = useParams<{ id: string }>();
|
|
51
|
+
const postId = Number(id);
|
|
52
|
+
const navigate = useNavigate();
|
|
53
|
+
const queryClient = useQueryClient();
|
|
54
|
+
const currentUser = useUserStore((s) => s.user);
|
|
55
|
+
|
|
56
|
+
const [isEditOpen, setIsEditOpen] = useState(false);
|
|
57
|
+
const [replyingToId, setReplyingToId] = useState<number | null>(null);
|
|
58
|
+
const [editingCommentId, setEditingCommentId] = useState<number | null>(null);
|
|
59
|
+
|
|
60
|
+
// 상세 화면 파일 미리보기용 blob URL 상태
|
|
61
|
+
const [blobUrls, setBlobUrls] = useState<Array<string>>([]);
|
|
62
|
+
const [previewFileNames, setPreviewFileNames] = useState<Array<string>>([]);
|
|
63
|
+
const [previewFileTypes, setPreviewFileTypes] = useState<Array<"image" | "pdf" | "unsupported">>([]);
|
|
64
|
+
|
|
65
|
+
// 수정 모달 파일 상태
|
|
66
|
+
const [pendingFiles, setPendingFiles] = useState<Array<File>>([]);
|
|
67
|
+
const [existingFiles, setExistingFiles] = useState<Array<FileResponse>>([]);
|
|
68
|
+
const [deleteFileIds, setDeleteFileIds] = useState<Array<number>>([]);
|
|
69
|
+
const [isEditUploading, setIsEditUploading] = useState(false);
|
|
70
|
+
|
|
71
|
+
const { data: post, isLoading } = useGetPost(postId);
|
|
72
|
+
const { data: comments = [] } = useGetComments("POST", postId);
|
|
73
|
+
const { mutateAsync: deletePost } = useDeletePost();
|
|
74
|
+
const { mutateAsync: deleteComment } = useDeleteComment();
|
|
75
|
+
const { mutateAsync: putComment, isPending: isCommentUpdating } = usePutComment();
|
|
76
|
+
|
|
77
|
+
const postFiles = post?.files ?? [];
|
|
78
|
+
const topComments = comments.filter((c) => c.parentCommentId === null);
|
|
79
|
+
const totalCommentCount = countComments(topComments);
|
|
80
|
+
|
|
81
|
+
// 첨부파일 blob URL 생성 (FormFile.tsx의 getFiles 패턴)
|
|
82
|
+
useEffect(() => {
|
|
83
|
+
if (postFiles.length === 0) {
|
|
84
|
+
setBlobUrls([]);
|
|
85
|
+
setPreviewFileNames([]);
|
|
86
|
+
setPreviewFileTypes([]);
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
let cancelled = false;
|
|
91
|
+
const createdUrls: Array<string> = [];
|
|
92
|
+
|
|
93
|
+
(async () => {
|
|
94
|
+
const results = await Promise.all(
|
|
95
|
+
postFiles.map(async (file) => {
|
|
96
|
+
try {
|
|
97
|
+
const { data: blob } = await apiFileInstance.get(file.filePath, {
|
|
98
|
+
responseType: "blob",
|
|
99
|
+
});
|
|
100
|
+
const url = URL.createObjectURL(blob);
|
|
101
|
+
createdUrls.push(url);
|
|
102
|
+
return { url, name: file.fileName, type: toPreviewFileType(file.fileType) };
|
|
103
|
+
} catch {
|
|
104
|
+
return { url: "", name: file.fileName, type: "unsupported" as const };
|
|
105
|
+
}
|
|
106
|
+
}),
|
|
107
|
+
);
|
|
108
|
+
|
|
109
|
+
if (!cancelled) {
|
|
110
|
+
setBlobUrls(results.map((r) => r.url));
|
|
111
|
+
setPreviewFileNames(results.map((r) => r.name));
|
|
112
|
+
setPreviewFileTypes(results.map((r) => r.type));
|
|
113
|
+
}
|
|
114
|
+
})();
|
|
115
|
+
|
|
116
|
+
return () => {
|
|
117
|
+
cancelled = true;
|
|
118
|
+
createdUrls.forEach((url) => url && URL.revokeObjectURL(url));
|
|
119
|
+
};
|
|
120
|
+
// postFiles 참조가 바뀔 때만 재실행 (길이 + 첫 id로 비교)
|
|
121
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
122
|
+
}, [postFiles.length, postFiles[0]?.id]);
|
|
123
|
+
|
|
124
|
+
const filePreview = useFilePreviewViewer(blobUrls);
|
|
125
|
+
|
|
126
|
+
const openEditModal = () => {
|
|
127
|
+
setPendingFiles([]);
|
|
128
|
+
setExistingFiles(post?.files ?? []);
|
|
129
|
+
setDeleteFileIds([]);
|
|
130
|
+
setIsEditOpen(true);
|
|
131
|
+
};
|
|
132
|
+
|
|
133
|
+
const closeEditModal = () => {
|
|
134
|
+
setIsEditOpen(false);
|
|
135
|
+
setPendingFiles([]);
|
|
136
|
+
setExistingFiles([]);
|
|
137
|
+
setDeleteFileIds([]);
|
|
138
|
+
};
|
|
139
|
+
|
|
140
|
+
const handleDeleteExistingFile = (fileId: number) => {
|
|
141
|
+
setExistingFiles((prev) => prev.filter((f) => f.id !== fileId));
|
|
142
|
+
setDeleteFileIds((prev) => [...prev, fileId]);
|
|
143
|
+
};
|
|
144
|
+
|
|
145
|
+
const handleEdit = async (data: PostFormData) => {
|
|
146
|
+
setIsEditUploading(true);
|
|
147
|
+
try {
|
|
148
|
+
const request = {
|
|
149
|
+
title: data.title,
|
|
150
|
+
content: data.content,
|
|
151
|
+
noticeCategory: data.noticeCategory,
|
|
152
|
+
visible: data.visible,
|
|
153
|
+
writer: currentUser?.userId ?? "",
|
|
154
|
+
deleteFileIds,
|
|
155
|
+
};
|
|
156
|
+
const formData = new FormData();
|
|
157
|
+
formData.append("request", JSON.stringify(request));
|
|
158
|
+
pendingFiles.forEach((f) => formData.append("files", f));
|
|
159
|
+
await apiFormDataInstance.put(`/posts/${postId}`, formData);
|
|
160
|
+
toast.success(COMMON_MESSAGES.UPDATE_SUCCESS);
|
|
161
|
+
void queryClient.invalidateQueries({ queryKey: [POST_QUERY_KEY] });
|
|
162
|
+
closeEditModal();
|
|
163
|
+
} finally {
|
|
164
|
+
setIsEditUploading(false);
|
|
165
|
+
}
|
|
166
|
+
};
|
|
167
|
+
|
|
168
|
+
const handleDelete = () => {
|
|
169
|
+
if (!post) return;
|
|
170
|
+
confirmModal({
|
|
171
|
+
content: `"${post.title}" 게시글을 삭제하시겠습니까?`,
|
|
172
|
+
onOk: async () => {
|
|
173
|
+
await deletePost(postId);
|
|
174
|
+
navigate("/post");
|
|
175
|
+
},
|
|
176
|
+
onCancel: () => {},
|
|
177
|
+
className: "max-w-100",
|
|
178
|
+
});
|
|
179
|
+
};
|
|
180
|
+
|
|
181
|
+
const isCommentOwner = (commentUserId: number | null) =>
|
|
182
|
+
commentUserId !== null && String(commentUserId) === String(currentUser?.id);
|
|
183
|
+
|
|
184
|
+
const handleCommentEditStart = (comment: Comment) => {
|
|
185
|
+
if (!isCommentOwner(comment.userId)) {
|
|
186
|
+
toast.error("본인의 댓글만 수정할 수 있습니다.");
|
|
187
|
+
return;
|
|
188
|
+
}
|
|
189
|
+
setEditingCommentId(comment.id);
|
|
190
|
+
};
|
|
191
|
+
|
|
192
|
+
const handleCommentDelete = (commentId: number, commentUserId: number | null) => {
|
|
193
|
+
if (!isCommentOwner(commentUserId)) {
|
|
194
|
+
toast.error("본인의 댓글만 삭제할 수 있습니다.");
|
|
195
|
+
return;
|
|
196
|
+
}
|
|
197
|
+
confirmModal({
|
|
198
|
+
content: "댓글을 삭제하시겠습니까?",
|
|
199
|
+
onOk: async () => {
|
|
200
|
+
await deleteComment(commentId);
|
|
201
|
+
},
|
|
202
|
+
onCancel: () => {},
|
|
203
|
+
className: "max-w-100",
|
|
204
|
+
});
|
|
205
|
+
};
|
|
206
|
+
|
|
207
|
+
const handleCommentSave = async (commentId: number, content: string, mentionUserIds: Array<number>) => {
|
|
208
|
+
await putComment({
|
|
209
|
+
commentId,
|
|
210
|
+
data: { content, mentionUserIds: mentionUserIds.length > 0 ? mentionUserIds : undefined },
|
|
211
|
+
});
|
|
212
|
+
setEditingCommentId(null);
|
|
213
|
+
};
|
|
214
|
+
|
|
215
|
+
const editDefaultValues: PostFormData | undefined = post
|
|
216
|
+
? {
|
|
217
|
+
title: post.title,
|
|
218
|
+
content: post.content,
|
|
219
|
+
noticeCategory: post.noticeCategory,
|
|
220
|
+
visible: post.visible ?? true,
|
|
221
|
+
}
|
|
222
|
+
: undefined;
|
|
223
|
+
|
|
224
|
+
const renderCommentActions = (comment: Comment) => {
|
|
225
|
+
if (!isCommentOwner(comment.userId)) return null;
|
|
226
|
+
return (
|
|
227
|
+
<div className="flex shrink-0 items-center gap-0.5">
|
|
228
|
+
<Button
|
|
229
|
+
type="button"
|
|
230
|
+
variant="ghost"
|
|
231
|
+
size="icon-sm"
|
|
232
|
+
onClick={() => handleCommentEditStart(comment)}
|
|
233
|
+
className="cursor-pointer rounded p-1 text-gray-400 hover:bg-gray-100 hover:text-blue-500"
|
|
234
|
+
aria-label="댓글 수정"
|
|
235
|
+
>
|
|
236
|
+
<Pencil size={14} />
|
|
237
|
+
</Button>
|
|
238
|
+
<Button
|
|
239
|
+
type="button"
|
|
240
|
+
variant="ghost"
|
|
241
|
+
size="icon-sm"
|
|
242
|
+
onClick={() => handleCommentDelete(comment.id, comment.userId)}
|
|
243
|
+
className="cursor-pointer rounded p-1 text-gray-400 hover:bg-gray-100 hover:text-red-500"
|
|
244
|
+
aria-label="댓글 삭제"
|
|
245
|
+
>
|
|
246
|
+
<Trash2 size={14} />
|
|
247
|
+
</Button>
|
|
248
|
+
</div>
|
|
249
|
+
);
|
|
250
|
+
};
|
|
251
|
+
|
|
252
|
+
return (
|
|
253
|
+
<div className="p-6">
|
|
254
|
+
<ListHeader
|
|
255
|
+
title="게시글 상세"
|
|
256
|
+
rightArea={
|
|
257
|
+
<div className="flex items-center gap-2">
|
|
258
|
+
<Button variant="outline" onClick={() => navigate("/post")}>
|
|
259
|
+
목록
|
|
260
|
+
</Button>
|
|
261
|
+
{post && (
|
|
262
|
+
<>
|
|
263
|
+
<Button variant="save" onClick={openEditModal}>
|
|
264
|
+
수정
|
|
265
|
+
</Button>
|
|
266
|
+
<Button variant="delete" onClick={handleDelete}>
|
|
267
|
+
삭제
|
|
268
|
+
</Button>
|
|
269
|
+
</>
|
|
270
|
+
)}
|
|
271
|
+
</div>
|
|
272
|
+
}
|
|
273
|
+
/>
|
|
274
|
+
|
|
275
|
+
<div className="mt-6 space-y-6">
|
|
276
|
+
{/* 게시글 - 공지사항 스타일 */}
|
|
277
|
+
<div className="overflow-hidden rounded-xl border border-gray-200 bg-white">
|
|
278
|
+
{isLoading ? (
|
|
279
|
+
<div className="p-8 text-center text-sm text-gray-400">불러오는 중...</div>
|
|
280
|
+
) : post ? (
|
|
281
|
+
<>
|
|
282
|
+
{/* 제목 + 메타 정보 */}
|
|
283
|
+
<div className="border-b border-gray-100 bg-gray-50/50 px-6 py-5">
|
|
284
|
+
<h2 className="mb-3 text-xl font-bold leading-tight text-gray-900">{post.title}</h2>
|
|
285
|
+
<div className="flex items-center gap-3 text-xs text-gray-500">
|
|
286
|
+
<Badge
|
|
287
|
+
text={post.visible ? "노출" : "미노출"}
|
|
288
|
+
className={`scale-90 ${post.visible ? "bg-green-100 text-green-700 border-green-100" : "bg-gray-100 text-gray-500 border-gray-200"}`}
|
|
289
|
+
/>
|
|
290
|
+
<span className="text-gray-300">|</span>
|
|
291
|
+
<span>작성자: {post.writer}</span>
|
|
292
|
+
<span className="text-gray-300">|</span>
|
|
293
|
+
<span>등록일: {formatDateTime(post.createdAt)}</span>
|
|
294
|
+
<span className="text-gray-300">|</span>
|
|
295
|
+
<span>수정일: {formatDateTime(post.updatedAt)}</span>
|
|
296
|
+
</div>
|
|
297
|
+
</div>
|
|
298
|
+
|
|
299
|
+
{/* 본문 */}
|
|
300
|
+
<div className="min-h-44 px-6 py-6">
|
|
301
|
+
<p className="whitespace-pre-wrap text-sm leading-7 text-gray-800">{post.content}</p>
|
|
302
|
+
</div>
|
|
303
|
+
|
|
304
|
+
{/* TODO fix issues */}
|
|
305
|
+
{/* 첨부파일 미리보기 뷰어 */}
|
|
306
|
+
{postFiles.length > 0 && (
|
|
307
|
+
<>
|
|
308
|
+
<div className="grid grid-cols-2 gap-2 w-full">
|
|
309
|
+
{postFiles
|
|
310
|
+
.map((postFile) => postFile.filePath)
|
|
311
|
+
.map((img, idx) => (
|
|
312
|
+
<FilePreviewCard
|
|
313
|
+
key={idx}
|
|
314
|
+
src={img}
|
|
315
|
+
fileName={getFileLabel(img, idx)}
|
|
316
|
+
onClick={() => filePreview.open(idx)}
|
|
317
|
+
/>
|
|
318
|
+
))}
|
|
319
|
+
</div>
|
|
320
|
+
<FilePreviewViewer
|
|
321
|
+
{...filePreview.viewerProps}
|
|
322
|
+
fileNames={previewFileNames}
|
|
323
|
+
fileTypes={previewFileTypes}
|
|
324
|
+
/>
|
|
325
|
+
</>
|
|
326
|
+
)}
|
|
327
|
+
</>
|
|
328
|
+
) : (
|
|
329
|
+
<div className="p-8 text-center text-sm text-gray-400">게시글을 찾을 수 없습니다.</div>
|
|
330
|
+
)}
|
|
331
|
+
</div>
|
|
332
|
+
|
|
333
|
+
{/* 댓글 섹션 */}
|
|
334
|
+
<div className="rounded-xl border border-gray-200 bg-white">
|
|
335
|
+
<div className="border-b border-gray-100 px-5 py-3">
|
|
336
|
+
<h3 className="text-sm font-semibold text-gray-700">댓글 {totalCommentCount}개</h3>
|
|
337
|
+
</div>
|
|
338
|
+
|
|
339
|
+
{topComments.length > 0 ? (
|
|
340
|
+
<ul className="divide-y divide-gray-100">
|
|
341
|
+
{topComments.map((comment) => {
|
|
342
|
+
const depth2Replies = comment.replies ?? [];
|
|
343
|
+
const isReplying = replyingToId === comment.id;
|
|
344
|
+
const isEditing = editingCommentId === comment.id;
|
|
345
|
+
|
|
346
|
+
return (
|
|
347
|
+
<li key={comment.id}>
|
|
348
|
+
{/* depth 1: 원댓글 */}
|
|
349
|
+
<div className="flex items-start gap-3 px-5 py-4">
|
|
350
|
+
<div className="flex-1 space-y-1">
|
|
351
|
+
<div className="flex items-center gap-2">
|
|
352
|
+
<span className="text-sm font-semibold text-gray-800">
|
|
353
|
+
{comment.userName || "알수없음"}
|
|
354
|
+
</span>
|
|
355
|
+
<span className="text-xs text-gray-400">{formatDateTime(comment.createdAt)}</span>
|
|
356
|
+
</div>
|
|
357
|
+
{isEditing ? (
|
|
358
|
+
<CommentInput
|
|
359
|
+
initialContent={comment.content}
|
|
360
|
+
initialMentionUserIds={comment.mentionUserIds ?? []}
|
|
361
|
+
onSave={(content, mentionUserIds) =>
|
|
362
|
+
handleCommentSave(comment.id, content, mentionUserIds)
|
|
363
|
+
}
|
|
364
|
+
isSaving={isCommentUpdating}
|
|
365
|
+
saveLabel="저장"
|
|
366
|
+
onCancel={() => setEditingCommentId(null)}
|
|
367
|
+
/>
|
|
368
|
+
) : (
|
|
369
|
+
<>
|
|
370
|
+
<p className="whitespace-pre-wrap text-sm text-gray-700">{comment.content}</p>
|
|
371
|
+
{!comment.deletedAt && (
|
|
372
|
+
<button
|
|
373
|
+
type="button"
|
|
374
|
+
onClick={() => setReplyingToId(isReplying ? null : comment.id)}
|
|
375
|
+
className="mt-0.5 flex items-center gap-1 text-xs text-gray-400 transition-colors hover:text-blue-500"
|
|
376
|
+
>
|
|
377
|
+
<CornerDownRight size={11} />
|
|
378
|
+
{isReplying ? "취소" : "답글"}
|
|
379
|
+
</button>
|
|
380
|
+
)}
|
|
381
|
+
</>
|
|
382
|
+
)}
|
|
383
|
+
</div>
|
|
384
|
+
{!isEditing && renderCommentActions(comment)}
|
|
385
|
+
</div>
|
|
386
|
+
|
|
387
|
+
{/* depth 2: 대댓글 목록 */}
|
|
388
|
+
{depth2Replies.length > 0 && (
|
|
389
|
+
<ul className="border-t border-gray-50 bg-gray-50/60">
|
|
390
|
+
{depth2Replies.map((reply) => {
|
|
391
|
+
const depth3Replies = reply.replies ?? [];
|
|
392
|
+
const isReplyingToReply = replyingToId === reply.id;
|
|
393
|
+
const isEditingReply = editingCommentId === reply.id;
|
|
394
|
+
|
|
395
|
+
return (
|
|
396
|
+
<li key={reply.id} className="border-b border-gray-100 last:border-0">
|
|
397
|
+
<div className="flex items-start gap-2.5 py-3 pl-10 pr-5">
|
|
398
|
+
<CornerDownRight size={13} className="mt-0.5 shrink-0 text-gray-300" />
|
|
399
|
+
<div className="flex-1 space-y-0.5">
|
|
400
|
+
<div className="flex items-center gap-2">
|
|
401
|
+
<span className="text-sm font-semibold text-gray-800">
|
|
402
|
+
{reply.userName}
|
|
403
|
+
</span>
|
|
404
|
+
<span className="text-xs text-gray-400">
|
|
405
|
+
{formatDateTime(reply.createdAt)}
|
|
406
|
+
</span>
|
|
407
|
+
</div>
|
|
408
|
+
{isEditingReply ? (
|
|
409
|
+
<CommentInput
|
|
410
|
+
initialContent={reply.content}
|
|
411
|
+
initialMentionUserIds={reply.mentionUserIds ?? []}
|
|
412
|
+
onSave={(content, mentionUserIds) =>
|
|
413
|
+
handleCommentSave(reply.id, content, mentionUserIds)
|
|
414
|
+
}
|
|
415
|
+
isSaving={isCommentUpdating}
|
|
416
|
+
saveLabel="저장"
|
|
417
|
+
onCancel={() => setEditingCommentId(null)}
|
|
418
|
+
/>
|
|
419
|
+
) : (
|
|
420
|
+
<>
|
|
421
|
+
<p className="whitespace-pre-wrap text-sm text-gray-600">
|
|
422
|
+
{reply.content}
|
|
423
|
+
</p>
|
|
424
|
+
{reply.content !== "삭제된 댓글입니다." && (
|
|
425
|
+
<button
|
|
426
|
+
type="button"
|
|
427
|
+
onClick={() => setReplyingToId(isReplyingToReply ? null : reply.id)}
|
|
428
|
+
className="mt-0.5 flex items-center gap-1 text-xs text-gray-400 transition-colors hover:text-blue-500"
|
|
429
|
+
>
|
|
430
|
+
<CornerDownRight size={11} />
|
|
431
|
+
{isReplyingToReply ? "취소" : "답글"}
|
|
432
|
+
</button>
|
|
433
|
+
)}
|
|
434
|
+
</>
|
|
435
|
+
)}
|
|
436
|
+
</div>
|
|
437
|
+
{!isEditingReply && renderCommentActions(reply)}
|
|
438
|
+
</div>
|
|
439
|
+
|
|
440
|
+
{/* depth 3: 대대댓글 목록 (답글 버튼 없음) */}
|
|
441
|
+
{depth3Replies.length > 0 && (
|
|
442
|
+
<ul className="bg-gray-100/50">
|
|
443
|
+
{depth3Replies.map((deepReply) => {
|
|
444
|
+
const isEditingDeepReply = editingCommentId === deepReply.id;
|
|
445
|
+
return (
|
|
446
|
+
<li
|
|
447
|
+
key={deepReply.id}
|
|
448
|
+
className="flex items-start gap-2.5 border-b border-gray-100 py-3 pl-16 pr-5 last:border-0"
|
|
449
|
+
>
|
|
450
|
+
<CornerDownRight
|
|
451
|
+
size={13}
|
|
452
|
+
className="mt-0.5 shrink-0 text-gray-200"
|
|
453
|
+
/>
|
|
454
|
+
<div className="flex-1 space-y-0.5">
|
|
455
|
+
<div className="flex items-center gap-2">
|
|
456
|
+
<span className="text-sm font-semibold text-gray-800">
|
|
457
|
+
{deepReply.userName}
|
|
458
|
+
</span>
|
|
459
|
+
<span className="text-xs text-gray-400">
|
|
460
|
+
{formatDateTime(deepReply.createdAt)}
|
|
461
|
+
</span>
|
|
462
|
+
</div>
|
|
463
|
+
{isEditingDeepReply ? (
|
|
464
|
+
<CommentInput
|
|
465
|
+
initialContent={deepReply.content}
|
|
466
|
+
initialMentionUserIds={deepReply.mentionUserIds ?? []}
|
|
467
|
+
onSave={(content, mentionUserIds) =>
|
|
468
|
+
handleCommentSave(deepReply.id, content, mentionUserIds)
|
|
469
|
+
}
|
|
470
|
+
isSaving={isCommentUpdating}
|
|
471
|
+
saveLabel="저장"
|
|
472
|
+
onCancel={() => setEditingCommentId(null)}
|
|
473
|
+
/>
|
|
474
|
+
) : (
|
|
475
|
+
<p className="whitespace-pre-wrap text-sm text-gray-600">
|
|
476
|
+
{deepReply.content}
|
|
477
|
+
</p>
|
|
478
|
+
)}
|
|
479
|
+
</div>
|
|
480
|
+
{!isEditingDeepReply && renderCommentActions(deepReply)}
|
|
481
|
+
</li>
|
|
482
|
+
);
|
|
483
|
+
})}
|
|
484
|
+
</ul>
|
|
485
|
+
)}
|
|
486
|
+
|
|
487
|
+
{/* depth 2 답글 입력창 */}
|
|
488
|
+
{isReplyingToReply && !isEditingReply && (
|
|
489
|
+
<div className="border-t border-blue-50 bg-blue-50/30 py-3 pl-10 pr-5">
|
|
490
|
+
<CommentInput
|
|
491
|
+
targetType="POST"
|
|
492
|
+
targetId={postId}
|
|
493
|
+
parentCommentId={reply.id}
|
|
494
|
+
placeholder="답글을 입력하세요. @를 입력하면 사용자를 멘션할 수 있습니다."
|
|
495
|
+
onSuccess={() => setReplyingToId(null)}
|
|
496
|
+
/>
|
|
497
|
+
</div>
|
|
498
|
+
)}
|
|
499
|
+
</li>
|
|
500
|
+
);
|
|
501
|
+
})}
|
|
502
|
+
</ul>
|
|
503
|
+
)}
|
|
504
|
+
|
|
505
|
+
{/* depth 1 답글 입력창 */}
|
|
506
|
+
{isReplying && !isEditing && (
|
|
507
|
+
<div className="border-t border-blue-50 bg-blue-50/30 py-3 pl-10 pr-5">
|
|
508
|
+
<CommentInput
|
|
509
|
+
targetType="POST"
|
|
510
|
+
targetId={postId}
|
|
511
|
+
parentCommentId={comment.id}
|
|
512
|
+
placeholder="답글을 입력하세요. @를 입력하면 사용자를 멘션할 수 있습니다."
|
|
513
|
+
onSuccess={() => setReplyingToId(null)}
|
|
514
|
+
/>
|
|
515
|
+
</div>
|
|
516
|
+
)}
|
|
517
|
+
</li>
|
|
518
|
+
);
|
|
519
|
+
})}
|
|
520
|
+
</ul>
|
|
521
|
+
) : (
|
|
522
|
+
<div className="px-5 py-6 text-center text-sm text-gray-400">등록된 댓글이 없습니다.</div>
|
|
523
|
+
)}
|
|
524
|
+
|
|
525
|
+
{/* 최상위 댓글 입력창 */}
|
|
526
|
+
<div className="border-t border-gray-100 px-5 py-4">
|
|
527
|
+
<CommentInput targetType="POST" targetId={postId} parentCommentId={null} />
|
|
528
|
+
</div>
|
|
529
|
+
</div>
|
|
530
|
+
</div>
|
|
531
|
+
|
|
532
|
+
{/* 수정 모달 */}
|
|
533
|
+
<PostFormModal
|
|
534
|
+
mode="edit"
|
|
535
|
+
isOpen={isEditOpen}
|
|
536
|
+
onClose={closeEditModal}
|
|
537
|
+
defaultValues={editDefaultValues}
|
|
538
|
+
onSubmit={handleEdit}
|
|
539
|
+
isPending={isEditUploading}
|
|
540
|
+
pendingFiles={pendingFiles}
|
|
541
|
+
onPendingFilesChange={setPendingFiles}
|
|
542
|
+
existingFiles={existingFiles}
|
|
543
|
+
onDeleteExistingFile={handleDeleteExistingFile}
|
|
544
|
+
formKey={postId}
|
|
545
|
+
/>
|
|
546
|
+
</div>
|
|
547
|
+
);
|
|
548
|
+
}
|