diff --git a/src/features/community-posts/api/community-post-api.ts b/src/features/community-posts/api/community-post-api.ts new file mode 100644 index 0000000..838e639 --- /dev/null +++ b/src/features/community-posts/api/community-post-api.ts @@ -0,0 +1,89 @@ +import { z } from "zod"; + +import { communityPostCreateRequestSchema, communityPostListResponseSchema, communityPostUpdateRequestSchema } from "@/features/community-posts/model/types"; +import type { CommunityPostCreateRequest, CommunityPostListData, CommunityPostUpdateRequest } from "@/features/community-posts/model/types"; +import type { ApiClient } from "@/shared/api/client"; + +export type GetCommunityPostsParams = { + readonly characterId: string; + readonly page?: number; + readonly size?: number; +}; + +export type UpdateCommunityPostParams = { + readonly postImage?: File; + readonly request: CommunityPostUpdateRequest; +}; + +export type CreateCommunityPostParams = { + readonly audioFile?: File; + readonly postImage?: File; + readonly request: CommunityPostCreateRequest; +}; + +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 createCommunityPostBody(audioFile: File | undefined, postImage: File | undefined, request: object): FormData { + const body = new FormData(); + if (audioFile !== undefined) { + body.append("audioFile", audioFile); + } + if (postImage !== undefined) { + body.append("postImage", postImage); + } + body.append("request", new Blob([JSON.stringify(request)], { type: "application/json" })); + + return body; +} + +function createCommunityPostCreateBody(params: CreateCommunityPostParams): FormData { + return createCommunityPostBody(params.audioFile, params.postImage, communityPostCreateRequestSchema.parse(params.request)); +} + +function createCommunityPostUpdateBody(params: UpdateCommunityPostParams): FormData { + return createCommunityPostBody(undefined, params.postImage, communityPostUpdateRequestSchema.parse(params.request)); +} + +export async function getCommunityPosts(apiClient: ApiClient, params: GetCommunityPostsParams): Promise { + const page = normalizePage(params.page); + const size = normalizeSize(params.size); + const query = new URLSearchParams({ page: String(page), size: String(size) }); + + return apiClient.request({ + path: `/api/v2/admin/ai-characters/${encodeURIComponent(params.characterId)}/community-posts?${query.toString()}`, + responseSchema: communityPostListResponseSchema, + authentication: "required", + }); +} + +export function updateCommunityPost(apiClient: ApiClient, characterId: string, postId: string, params: UpdateCommunityPostParams): Promise { + return apiClient.request({ + path: `/api/v2/admin/ai-characters/${encodeURIComponent(characterId)}/community-posts/${encodeURIComponent(postId)}`, + method: "PUT", + body: createCommunityPostUpdateBody(params), + responseSchema: nullSuccessSchema, + authentication: "required", + }); +} + +export function createCommunityPost(apiClient: ApiClient, characterId: string, params: CreateCommunityPostParams): Promise { + return apiClient.request({ + path: `/api/v2/admin/ai-characters/${encodeURIComponent(characterId)}/community-posts`, + method: "POST", + body: createCommunityPostCreateBody(params), + responseSchema: nullSuccessSchema, + authentication: "required", + }); +} + +export function softDeleteCommunityPost(apiClient: ApiClient, characterId: string, postId: string): Promise { + return updateCommunityPost(apiClient, characterId, postId, { request: { isActive: false, isFixed: false } }); +} diff --git a/src/features/community-posts/components/CommunityPostForm.tsx b/src/features/community-posts/components/CommunityPostForm.tsx new file mode 100644 index 0000000..1d6725e --- /dev/null +++ b/src/features/community-posts/components/CommunityPostForm.tsx @@ -0,0 +1,177 @@ +import { useEffect, useRef, useState } from "react"; + +import { createCommunityPost } from "@/features/community-posts/api/community-post-api"; +import { communityPostAudioErrorMessage, formatCommunityPostPrice, hasCommunityPostFormErrors, parseCommunityPostPrice } from "@/features/community-posts/components/community-post-form-helpers"; +import type { CommunityPostFormErrors } from "@/features/community-posts/components/community-post-form-helpers"; +import { COMMUNITY_POST_IMAGE_POLICY, prepareCommunityPostImage } from "@/features/community-posts/validation/community-post-media-policy"; +import { ApiError } from "@/shared/api/api-error"; +import type { ApiClient } from "@/shared/api/client"; +import type { CropRenderRequest } from "@/shared/lib/crop-image"; +import { focusFirstInvalidControl } from "@/shared/lib/focus-first-invalid-control"; +import { FileField } from "@/shared/ui/file-field"; +import { ImageCropDialog } from "@/shared/ui/image-crop-dialog"; +import type { CropSourceImage } from "@/shared/ui/image-crop-dialog"; +import { AUDIO_FILE_POLICY } from "@/shared/validation/audio-file-policy"; +import { CAN_PRICE_MAX } from "@/shared/validation/can-price"; + +type CommunityPostFormProps = { + readonly apiClient: ApiClient; + readonly characterId: string; + readonly createCropSource: (file: File) => Promise; + readonly onCreated: () => void; + readonly renderCrop?: (request: CropRenderRequest) => Promise; +}; + +const errorIds = { + content: "community-post-content-error", + price: "community-post-price-error", +} as const; + +function buildCreateRequest(content: string, isAdult: boolean, isCommentAvailable: boolean, price: number | null) { + const base = { content, isAdult, isCommentAvailable }; + + return price === null ? base : { ...base, price }; +} + +export function CommunityPostForm({ apiClient, characterId, createCropSource, onCreated, renderCrop }: CommunityPostFormProps) { + const [audioFile, setAudioFile] = useState(null); + const [content, setContent] = useState(""); + const [cropSource, setCropSource] = useState(null); + const [errors, setErrors] = useState({}); + const [isAdult, setIsAdult] = useState(false); + const [isCommentAvailable, setIsCommentAvailable] = useState(true); + const [isImagePreparing, setIsImagePreparing] = useState(false); + const [isSaving, setIsSaving] = useState(false); + const [postImage, setPostImage] = useState(null); + const [price, setPrice] = useState(""); + const formRef = useRef(null); + const imageSelectionId = useRef(0); + const isSavingRef = useRef(false); + + useEffect(() => () => cropSource?.release?.(), [cropSource]); + + async function selectPostImage(file: File | null) { + imageSelectionId.current += 1; + const currentSelectionId = imageSelectionId.current; + if (file === null) { + setPostImage(null); + setCropSource(null); + setIsImagePreparing(false); + setErrors((current) => ({ ...current, image: undefined })); + return; + } + + setIsImagePreparing(true); + try { + const prepared = await prepareCommunityPostImage(file, createCropSource); + if (currentSelectionId !== imageSelectionId.current) { + if (prepared.kind === "crop") { + prepared.source.release?.(); + } + return; + } + switch (prepared.kind) { + case "cleared": + setPostImage(null); + setCropSource(null); + setErrors((current) => ({ ...current, image: undefined })); + break; + case "crop": + setPostImage(null); + setCropSource(prepared.source); + setErrors((current) => ({ ...current, image: undefined })); + break; + case "error": + setPostImage(null); + setCropSource(null); + setErrors((current) => ({ ...current, image: prepared.message })); + break; + case "ready": + setPostImage(prepared.file); + setCropSource(null); + setErrors((current) => ({ ...current, image: undefined })); + break; + } + } catch (error: unknown) { + if (currentSelectionId !== imageSelectionId.current) { + return; + } + if (error instanceof Error) { + setErrors((current) => ({ ...current, image: "이미지 미리보기를 불러오지 못했습니다." })); + return; + } + throw error; + } finally { + if (currentSelectionId === imageSelectionId.current) { + setIsImagePreparing(false); + } + } + } + + function validateForm(): CommunityPostFormErrors { + const parsedPrice = parseCommunityPostPrice(price); + const hasPriceInput = price.trim().length > 0; + + return { + audio: communityPostAudioErrorMessage(audioFile), + content: content.trim().length === 0 ? "내용을 입력하세요." : undefined, + image: isImagePreparing || cropSource !== null ? "이미지 처리가 끝난 뒤 저장하세요." : errors.image, + price: (hasPriceInput && parsedPrice === null) || (parsedPrice !== null && (!Number.isInteger(parsedPrice) || parsedPrice < 0 || parsedPrice > CAN_PRICE_MAX)) ? "가격은 0 이상 99,999 이하 정수 캔으로 입력하세요." : undefined, + }; + } + + async function submit(event: { readonly preventDefault: () => void }) { + event.preventDefault(); + if (isSavingRef.current) { + return; + } + const nextErrors = validateForm(); + setErrors(nextErrors); + if (hasCommunityPostFormErrors(nextErrors)) { + queueMicrotask(() => focusFirstInvalidControl(formRef.current)); + return; + } + + isSavingRef.current = true; + setIsSaving(true); + try { + await createCommunityPost(apiClient, characterId, { + audioFile: audioFile ?? undefined, + postImage: postImage ?? undefined, + request: buildCreateRequest(content.trim(), isAdult, isCommentAvailable, parseCommunityPostPrice(price)), + }); + onCreated(); + } catch (error: unknown) { + setErrors({ form: error instanceof ApiError ? error.message : "커뮤니티 게시글을 생성하지 못했습니다." }); + } finally { + isSavingRef.current = false; + setIsSaving(false); + } + } + + return ( +
+
+

COMMUNITY

+

커뮤니티 게시글 생성

+

내용과 첨부 파일은 저장 전 운영 기준에 맞게 검토하세요. GIF는 crop 없이 원본을 보존합니다.

+
+
void submit(event)} ref={formRef}> +