From 3faca9013582dfe5d12a80a4e342e93a157210e4 Mon Sep 17 00:00:00 2001 From: Yu Sung Date: Sat, 1 Aug 2026 01:30:43 +0900 Subject: [PATCH] =?UTF-8?q?feat(ai-character):=20=EB=8C=93=EA=B8=80?= =?UTF-8?q?=EA=B3=BC=20=ED=8C=AC=ED=86=A1=20=EC=9A=B4=EC=98=81=20=EA=B8=B0?= =?UTF-8?q?=EB=8A=A5=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/features/comments/api/comment-api.ts | 103 ++++++ .../comments/components/CommentForm.tsx | 47 +++ .../comments/components/CommentItem.tsx | 47 +++ .../comments/components/CommentThread.tsx | 182 ++++++++++ src/features/comments/model/types.ts | 48 +++ .../comments/tests/comment-contract.test.ts | 200 +++++++++++ .../tests/comment-thread-pending.test.tsx | 56 ++++ .../comments/tests/comment-thread.test.tsx | 317 ++++++++++++++++++ src/features/fan-talks/api/fan-talk-api.ts | 90 +++++ .../fan-talks/components/FanTalkList.tsx | 44 +++ .../fan-talks/components/FanTalkListItem.tsx | 22 ++ .../fan-talks/components/FanTalkReplyForm.tsx | 47 +++ .../components/FanTalkReplySheet.tsx | 123 +++++++ src/features/fan-talks/model/types.ts | 32 ++ .../fan-talks/pages/FanTalkListPage.tsx | 101 ++++++ .../schemas/fan-talk-reply-schema.ts | 25 ++ .../fan-talks/tests/fan-talk-contract.test.ts | 147 ++++++++ .../tests/fan-talk-delete-pending.test.tsx | 43 +++ .../fan-talks/tests/fan-talk-list.test.tsx | 181 ++++++++++ .../fan-talks/tests/fan-talk-reply.test.tsx | 299 +++++++++++++++++ 20 files changed, 2154 insertions(+) create mode 100644 src/features/comments/api/comment-api.ts create mode 100644 src/features/comments/components/CommentForm.tsx create mode 100644 src/features/comments/components/CommentItem.tsx create mode 100644 src/features/comments/components/CommentThread.tsx create mode 100644 src/features/comments/model/types.ts create mode 100644 src/features/comments/tests/comment-contract.test.ts create mode 100644 src/features/comments/tests/comment-thread-pending.test.tsx create mode 100644 src/features/comments/tests/comment-thread.test.tsx create mode 100644 src/features/fan-talks/api/fan-talk-api.ts create mode 100644 src/features/fan-talks/components/FanTalkList.tsx create mode 100644 src/features/fan-talks/components/FanTalkListItem.tsx create mode 100644 src/features/fan-talks/components/FanTalkReplyForm.tsx create mode 100644 src/features/fan-talks/components/FanTalkReplySheet.tsx create mode 100644 src/features/fan-talks/model/types.ts create mode 100644 src/features/fan-talks/pages/FanTalkListPage.tsx create mode 100644 src/features/fan-talks/schemas/fan-talk-reply-schema.ts create mode 100644 src/features/fan-talks/tests/fan-talk-contract.test.ts create mode 100644 src/features/fan-talks/tests/fan-talk-delete-pending.test.tsx create mode 100644 src/features/fan-talks/tests/fan-talk-list.test.tsx create mode 100644 src/features/fan-talks/tests/fan-talk-reply.test.tsx diff --git a/src/features/comments/api/comment-api.ts b/src/features/comments/api/comment-api.ts new file mode 100644 index 0000000..a2e53ef --- /dev/null +++ b/src/features/comments/api/comment-api.ts @@ -0,0 +1,103 @@ +import { z } from "zod"; + +import { audioCommentCreateRequestSchema, commentPageSchema, commentUpdateRequestSchema, communityCommentCreateRequestSchema } from "@/features/comments/model/types"; +import type { AudioCommentCreateRequest, CommentPage, CommentTarget, CommentUpdateRequest, CommunityCommentCreateRequest } from "@/features/comments/model/types"; +import type { ApiClient } from "@/shared/api/client"; + +export type CommentPageParams = { + readonly page?: number; + readonly size?: number; +}; + +export type GetRepliesParams = CommentPageParams & { + readonly commentId: number; +}; + +export type UpdateCommentParams = { + readonly commentId: number; + readonly request: CommentUpdateRequest; +}; + +export type DeleteCommentParams = { + readonly commentId: number; +}; + +const nullSuccessSchema = z.null(); + +function normalizePage(value: number | undefined): number { + return Math.max(0, Math.trunc(value ?? 0)); +} + +function normalizeSize(value: number | undefined): number { + return Math.max(1, Math.trunc(value ?? 20)); +} + +function commentCollectionPath(target: CommentTarget): string { + switch (target.kind) { + case "audio": + return `/api/v2/admin/ai-characters/${encodeURIComponent(target.characterId)}/audio-contents/${encodeURIComponent(target.contentId)}/comments`; + case "community": + return `/api/v2/admin/ai-characters/${encodeURIComponent(target.characterId)}/community-posts/${encodeURIComponent(target.postId)}/comments`; + } +} + +function commentItemPath(target: CommentTarget, commentId: number): string { + return `${commentCollectionPath(target)}/${encodeURIComponent(String(commentId))}`; +} + +function pageQuery(params: CommentPageParams): string { + return new URLSearchParams({ page: String(normalizePage(params.page)), size: String(normalizeSize(params.size)) }).toString(); +} + +export function getRootComments(apiClient: ApiClient, target: CommentTarget, params: CommentPageParams = {}): Promise { + return apiClient.request({ + path: `${commentCollectionPath(target)}?${pageQuery(params)}`, + responseSchema: commentPageSchema, + authentication: "required", + }); +} + +export function getReplies(apiClient: ApiClient, target: CommentTarget, params: GetRepliesParams): Promise { + return apiClient.request({ + path: `${commentItemPath(target, params.commentId)}/replies?${pageQuery(params)}`, + responseSchema: commentPageSchema, + authentication: "required", + }); +} + +export function createComment(apiClient: ApiClient, target: Extract, request: AudioCommentCreateRequest): Promise; +export function createComment(apiClient: ApiClient, target: Extract, request: CommunityCommentCreateRequest): Promise; +export function createComment(apiClient: ApiClient, target: CommentTarget, request: AudioCommentCreateRequest | CommunityCommentCreateRequest): Promise { + const body = target.kind === "audio" + ? audioCommentCreateRequestSchema.parse(request) + : communityCommentCreateRequestSchema.parse(request); + + return apiClient.request({ + path: commentCollectionPath(target), + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + responseSchema: nullSuccessSchema, + authentication: "required", + }); +} + +export function updateComment(apiClient: ApiClient, target: CommentTarget, params: UpdateCommentParams): Promise { + return apiClient.request({ + path: commentItemPath(target, params.commentId), + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(commentUpdateRequestSchema.parse(params.request)), + responseSchema: nullSuccessSchema, + authentication: "required", + }); +} + +export function deleteComment(apiClient: ApiClient, target: CommentTarget, params: DeleteCommentParams): Promise { + return apiClient.request({ + path: commentItemPath(target, params.commentId), + method: "DELETE", + responseSchema: nullSuccessSchema, + authentication: "required", + }); +} diff --git a/src/features/comments/components/CommentForm.tsx b/src/features/comments/components/CommentForm.tsx new file mode 100644 index 0000000..5005534 --- /dev/null +++ b/src/features/comments/components/CommentForm.tsx @@ -0,0 +1,47 @@ +import { useRef, useState } from "react"; + +import { focusFirstInvalidControl } from "@/shared/lib/focus-first-invalid-control"; + +export function CommentForm({ errorId, isSaving, label, onSubmit, submitLabel }: { readonly errorId: string; readonly isSaving: boolean; readonly label: string; readonly onSubmit: (comment: string) => Promise; readonly submitLabel: string }) { + const [comment, setComment] = useState(""); + const [errorMessage, setErrorMessage] = useState(null); + const formRef = useRef(null); + const trimmedComment = comment.trim(); + + async function submit() { + if (isSaving) { + return; + } + + if (trimmedComment.length === 0) { + setErrorMessage("댓글 내용을 입력해 주세요."); + queueMicrotask(() => focusFirstInvalidControl(formRef.current)); + return; + } + + const isSubmitted = await onSubmit(trimmedComment); + if (isSubmitted) { + setComment(""); + setErrorMessage(null); + } + } + + return ( +
{ event.preventDefault(); void submit(); }} ref={formRef}> +