import { useEffect, useRef, useState } from "react"; import { navigateTo } from "@/app/browser-location"; import { routePaths } from "@/app/route-paths"; import { createAudioContentCreateBody, deactivateAudioContent, updateAudioContent } from "@/features/audio-contents/api/audio-content-api"; import { uploadAudioContent } from "@/features/audio-contents/api/upload-audio-content"; import type { UploadAudioContentOptions, UploadAuthDependencies } from "@/features/audio-contents/api/upload-audio-content"; import { audioErrorMessage, coverErrorMessage, createInitialPrice, defaultAudioContentCreateSettings, isFutureLocalDateTime, parsePrice, toCreateRequest, toUpdateRequest } from "@/features/audio-contents/components/audio-content-form-helpers"; import type { AudioContentCreateSettings } from "@/features/audio-contents/components/audio-content-form-helpers"; import { AudioContentCreateOptions } from "@/features/audio-contents/components/AudioContentCreateOptions"; import { AudioContentThemeSelect } from "@/features/audio-contents/components/AudioContentThemeSelect"; import { ReleaseScheduleField } from "@/features/audio-contents/components/ReleaseScheduleField"; import type { ReleaseScheduleValue } from "@/features/audio-contents/components/ReleaseScheduleField"; import type { AudioContentDetail } from "@/features/audio-contents/model/types"; import { audioContentCreateResponseSchema } from "@/features/audio-contents/schemas/audio-content-schema"; import type { AudioContentTheme } from "@/features/audio-contents/schemas/audio-content-schema"; import { AUDIO_COVER_POLICY } from "@/features/audio-contents/validation/audio-cover-policy"; import type { ApiClient } from "@/shared/api/client"; import { ApiError } from "@/shared/api/api-error"; import type { CropRenderRequest } from "@/shared/lib/crop-image"; import { focusFirstInvalidControl } from "@/shared/lib/focus-first-invalid-control"; import { formatSeoulDateTime } from "@/shared/lib/formatters"; import { ConfirmDeactivateDialog } from "@/shared/ui/confirm-deactivate-dialog"; import { CanPriceField } from "@/shared/ui/can-price-field"; 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 { TagInput } from "@/shared/ui/tag-input"; import { CAN_PRICE_MAX } from "@/shared/validation/can-price"; import { AUDIO_FILE_POLICY } from "@/shared/validation/audio-file-policy"; import { UnsavedChangesGuard } from "@/shared/ui/unsaved-changes-guard"; import { UploadProgress } from "@/shared/ui/upload-progress"; import type { UploadProgressStatus } from "@/shared/ui/upload-progress"; export type UploadAudioContentRequest = (options: UploadAudioContentOptions) => Promise; type FieldName = "audio" | "cover" | "detail" | "form" | "price" | "releaseDate" | "tags" | "theme" | "title"; type FieldErrors = Partial>; type UploadState = { readonly progress: number; readonly status: UploadProgressStatus; }; const errorIds = { detail: "audio-content-detail-error", price: "audio-content-price-error", releaseDate: "audio-content-release-date-error", tags: "audio-content-tags-error", theme: "audio-content-theme-error", title: "audio-content-title-error", } as const; export function AudioContentForm({ apiClient, audio, characterId, createCropSource, mode, renderCrop, themes, uploadAuth, uploadAudioContentRequest = uploadAudioContent }: { readonly apiClient: ApiClient; readonly audio?: AudioContentDetail; readonly characterId: string; readonly createCropSource: (file: File) => Promise; readonly mode: "create" | "edit"; readonly renderCrop?: (request: CropRenderRequest) => Promise; readonly themes: readonly AudioContentTheme[]; readonly uploadAuth?: UploadAuthDependencies; readonly uploadAudioContentRequest?: UploadAudioContentRequest }) { const [audioFile, setAudioFile] = useState(null); const [createSettings, setCreateSettings] = useState(defaultAudioContentCreateSettings); const [coverImage, setCoverImage] = useState(null); const [cropSource, setCropSource] = useState(null); const [detail, setDetail] = useState(audio?.detail ?? ""); const [errors, setErrors] = useState({}); const [isDeactivateDialogOpen, setIsDeactivateDialogOpen] = useState(false); const [isCoverPreparing, setIsCoverPreparing] = useState(false); const [isDeactivating, setIsDeactivating] = useState(false); const [deactivateError, setDeactivateError] = useState(); const [price, setPrice] = useState(createInitialPrice(audio?.price)); const [releaseSchedule, setReleaseSchedule] = useState({ publishMode: "immediate", releaseDateTime: "" }); const [tags, setTags] = useState(audio?.tag ?? ""); const [themeId, setThemeId] = useState(null); const [title, setTitle] = useState(audio?.title ?? ""); const [uploadState, setUploadState] = useState({ progress: 0, status: "idle" }); const [abortController, setAbortController] = useState(null); const formRef = useRef(null); const coverSelectionIdRef = useRef(0); const isDeactivatingRef = useRef(false); const isSubmittingRef = useRef(false); const contentId = audio === undefined ? null : String(audio.contentId); const isDirty = title !== (audio?.title ?? "") || detail !== (audio?.detail ?? "") || tags !== (audio?.tag ?? "") || price !== createInitialPrice(audio?.price) || audioFile !== null || coverImage !== null || themeId !== null || releaseSchedule.publishMode !== "immediate" || releaseSchedule.releaseDateTime !== "" || createSettings !== defaultAudioContentCreateSettings; const isCoverSubmitBlocked = isCoverPreparing || cropSource !== null; const isPaid = (parsePrice(price) ?? 0) > 0; useEffect(() => () => cropSource?.release?.(), [cropSource]); function changePrice(value: string) { setPrice(value); if (mode === "create" && parsePrice(value) === 0) { setCreateSettings((current) => ({ ...current, purchaseOption: "BOTH", isGeneratePreview: false, isPointAvailable: false, previewStartTime: null, previewEndTime: null })); } } async function selectCoverImage(file: File | null) { const selectionId = coverSelectionIdRef.current + 1; coverSelectionIdRef.current = selectionId; if (file === null) { setCoverImage(null); setCropSource(null); setIsCoverPreparing(false); return; } setCoverImage(null); setCropSource(null); const coverError = coverErrorMessage(file, mode); if (coverError !== undefined) { setErrors((current) => ({ ...current, cover: coverError })); return; } setIsCoverPreparing(true); setErrors((current) => ({ ...current, cover: undefined })); try { const source = await createCropSource(file); if (coverSelectionIdRef.current === selectionId) { setCropSource(source); } else { source.release?.(); } } catch { if (coverSelectionIdRef.current === selectionId) { setErrors((current) => ({ ...current, cover: "이미지 미리보기 준비에 실패했습니다." })); } } finally { if (coverSelectionIdRef.current === selectionId) { setIsCoverPreparing(false); } } } function validateForm(): FieldErrors { const parsedPrice = parsePrice(price); return { audio: audioErrorMessage(audioFile, mode), cover: isCoverSubmitBlocked ? "이미지 처리가 끝난 뒤 저장하세요." : coverErrorMessage(coverImage, mode), detail: detail.trim().length === 0 ? "상세 설명을 입력하세요." : undefined, price: parsedPrice === null || parsedPrice < 0 || parsedPrice > CAN_PRICE_MAX || !Number.isInteger(parsedPrice) ? "가격은 0 이상 99,999 이하 정수 캔으로 입력하세요." : undefined, releaseDate: releaseSchedule.publishMode === "scheduled" && !isFutureLocalDateTime(releaseSchedule.releaseDateTime) ? "미래 Asia/Seoul 시각을 입력하세요." : undefined, tags: tags.trim().length === 0 ? "태그를 입력하세요." : undefined, theme: mode === "create" && themeId === null ? "테마를 선택하세요." : undefined, title: title.trim().length === 0 ? "제목을 입력하세요." : undefined, }; } function hasErrors(nextErrors: FieldErrors): boolean { return Object.values(nextErrors).some((error) => error !== undefined); } async function submitWithCurrentState() { if (isSubmittingRef.current) { return; } const nextErrors = validateForm(); setErrors(nextErrors); if (hasErrors(nextErrors)) { queueMicrotask(() => focusFirstInvalidControl(formRef.current)); return; } const parsedPrice = parsePrice(price); if (parsedPrice === null) { return; } isSubmittingRef.current = true; setUploadState({ progress: 0, status: "uploading" }); try { if (mode === "create" && audioFile !== null && coverImage !== null && themeId !== null) { const request = toCreateRequest({ detail: detail.trim(), price: parsedPrice, releaseSchedule, settings: createSettings, tags: tags.trim(), themeId, title: title.trim() }); const controller = new AbortController(); setAbortController(controller); const result = await uploadAudioContentRequest({ auth: uploadAuth, authentication: "required", body: createAudioContentCreateBody({ characterId, contentFile: audioFile, coverImage, request }), method: "POST", onProgress: (progress) => setUploadState({ progress, status: "uploading" }), path: `/api/v2/admin/ai-characters/${encodeURIComponent(characterId)}/audio-contents`, responseSchema: audioContentCreateResponseSchema, signal: controller.signal }); setAbortController(null); setUploadState({ progress: 100, status: "success" }); navigateTo(routePaths.aiCharacterAudioContentDetail(characterId, String(result.contentId)), { successNotification: "오디오 콘텐츠를 생성했습니다." }); return; } if (mode === "edit" && audio !== undefined && contentId !== null) { await updateAudioContent(apiClient, characterId, contentId, { coverImage: coverImage ?? undefined, request: toUpdateRequest({ audio, detail: detail.trim(), price: parsedPrice, tags: tags.trim(), title: title.trim() }) }); setUploadState({ progress: 100, status: "success" }); navigateTo(routePaths.aiCharacterAudioContentDetail(characterId, contentId), { successNotification: "오디오 콘텐츠를 저장했습니다." }); } } catch (error) { setAbortController(null); if (error instanceof DOMException && error.name === "AbortError") { setErrors({}); setUploadState((current) => ({ progress: current.progress, status: "canceled" })); return; } if (error instanceof ApiError && error.status === 415 && error.errorProperty === "contentFile") { setErrors({ audio: error.message }); } else if (error instanceof ApiError && error.status === 415 && error.errorProperty === "coverImage") { setErrors({ cover: error.message }); } else { setErrors({ form: error instanceof ApiError ? error.message : "오디오 콘텐츠 저장에 실패했습니다." }); } setUploadState((current) => ({ progress: current.progress, status: "error" })); } finally { isSubmittingRef.current = false; } } async function submit(event: { readonly preventDefault: () => void }) { event.preventDefault(); await submitWithCurrentState(); } async function confirmDeactivate() { if (contentId === null || isDeactivatingRef.current) { return; } isDeactivatingRef.current = true; setIsDeactivating(true); setDeactivateError(undefined); try { await deactivateAudioContent(apiClient, characterId, contentId); navigateTo(routePaths.aiCharacterAudioContents(characterId), { successNotification: "오디오 콘텐츠를 비활성화했습니다." }); } catch (error: unknown) { if (!(error instanceof Error)) { throw error; } setDeactivateError("비활성화하지 못했습니다. 다시 시도하세요."); } finally { isDeactivatingRef.current = false; setIsDeactivating(false); } } return ( {(requestRouteLeave) => (

AUDIO CONTENTS

{mode === "create" ? "오디오 콘텐츠 생성" : "오디오 콘텐츠 수정"}

제목, 상세 설명, 태그는 저장 전 운영 기준에 맞게 검토하세요. 업로드 제한은 안내된 파일 정책을 따릅니다.

void submit(event)} ref={formRef}> void selectCoverImage(file)} value={coverImage} /> {mode === "create" ? :

오디오 원본 파일은 수정할 수 없습니다.

} {errors.title === undefined ? null : }