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}> +