@farmzone/fz-template-react 1.0.5 → 1.0.7

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