diff --git a/src/features/audio-contents/api/audio-content-api.ts b/src/features/audio-contents/api/audio-content-api.ts new file mode 100644 index 0000000..e193f25 --- /dev/null +++ b/src/features/audio-contents/api/audio-content-api.ts @@ -0,0 +1,127 @@ +import type { AudioContentDetail, AudioContentListItem } from "@/features/audio-contents/model/types"; +import { audioContentDetailSchema, audioContentListResponseSchema } from "@/features/audio-contents/model/types"; +import { audioContentCreateRequestSchema, audioContentCreateResponseSchema, audioContentDeactivateRequestSchema, audioContentUpdateRequestSchema } from "@/features/audio-contents/schemas/audio-content-schema"; +import type { AudioContentCreateRequest, AudioContentCreateResponse, AudioContentDeactivateRequest, AudioContentUpdateRequest } from "@/features/audio-contents/schemas/audio-content-schema"; +import type { ApiClient } from "@/shared/api/client"; +import type { PageData } from "@/shared/api/pagination"; +import { z } from "zod"; + +export type GetAudioContentsParams = { + readonly characterId: string; + readonly page?: number; + readonly search_word?: string; + readonly size?: number; +}; + +export type GetAudioContentParams = { + readonly characterId: string; + readonly contentId: string; +}; + +export type CreateAudioContentParams = { + readonly characterId: string; + readonly contentFile: File; + readonly coverImage: File; + readonly request: AudioContentCreateRequest; +}; + +export type UpdateAudioContentParams = { + readonly coverImage?: File; + readonly request: AudioContentUpdateRequest; +}; + +const nullSuccessSchema = z.null(); + +function createAudioContentFormData(contentFile: File | undefined, coverImage: File | undefined, request: object): FormData { + const body = new FormData(); + if (contentFile !== undefined) { + body.append("contentFile", contentFile); + } + if (coverImage !== undefined) { + body.append("coverImage", coverImage); + } + body.append("request", new Blob([JSON.stringify(request)], { type: "application/json" })); + + return body; +} + +export function createAudioContentCreateBody(params: CreateAudioContentParams): FormData { + return createAudioContentFormData(params.contentFile, params.coverImage, audioContentCreateRequestSchema.parse(params.request)); +} + +export function createAudioContentUpdateBody(params: UpdateAudioContentParams): FormData { + return createAudioContentFormData(undefined, params.coverImage, audioContentUpdateRequestSchema.parse(params.request)); +} + +export function createAudioContentDeactivateBody(request: AudioContentDeactivateRequest): FormData { + return createAudioContentFormData(undefined, undefined, audioContentDeactivateRequestSchema.parse(request)); +} + +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)); +} + +export async function getAudioContents(apiClient: ApiClient, params: GetAudioContentsParams): Promise> { + const page = normalizePage(params.page); + const size = normalizeSize(params.size); + const query = new URLSearchParams({ page: String(page), size: String(size) }); + const searchWord = params.search_word?.trim(); + if (searchWord !== undefined && searchWord.length >= 2) { + query.set("search_word", searchWord); + } + const response = await apiClient.request({ + path: `/api/v2/admin/ai-characters/${encodeURIComponent(params.characterId)}/audio-contents?${query.toString()}`, + responseSchema: audioContentListResponseSchema, + authentication: "required", + }); + + return { + totalCount: response.totalCount, + page, + size, + hasNext: (page + 1) * size < response.totalCount, + items: response.items, + }; +} + +export function getAudioContent(apiClient: ApiClient, params: GetAudioContentParams): Promise { + return apiClient.request({ + path: `/api/v2/admin/ai-characters/${encodeURIComponent(params.characterId)}/audio-contents/${encodeURIComponent(params.contentId)}`, + responseSchema: audioContentDetailSchema, + authentication: "required", + }); +} + +export function createAudioContent(apiClient: ApiClient, params: CreateAudioContentParams): Promise { + return apiClient.request({ + path: `/api/v2/admin/ai-characters/${encodeURIComponent(params.characterId)}/audio-contents`, + method: "POST", + body: createAudioContentCreateBody(params), + responseSchema: audioContentCreateResponseSchema, + authentication: "required", + }); +} + +export function updateAudioContent(apiClient: ApiClient, characterId: string, contentId: string, params: UpdateAudioContentParams): Promise { + return apiClient.request({ + path: `/api/v2/admin/ai-characters/${encodeURIComponent(characterId)}/audio-contents/${encodeURIComponent(contentId)}`, + method: "PUT", + body: createAudioContentUpdateBody(params), + responseSchema: nullSuccessSchema, + authentication: "required", + }); +} + +export function deactivateAudioContent(apiClient: ApiClient, characterId: string, contentId: string): Promise { + return apiClient.request({ + path: `/api/v2/admin/ai-characters/${encodeURIComponent(characterId)}/audio-contents/${encodeURIComponent(contentId)}`, + method: "PUT", + body: createAudioContentDeactivateBody({ isActive: false }), + responseSchema: nullSuccessSchema, + authentication: "required", + }); +} diff --git a/src/features/audio-contents/api/audio-content-theme-api.ts b/src/features/audio-contents/api/audio-content-theme-api.ts new file mode 100644 index 0000000..771e7da --- /dev/null +++ b/src/features/audio-contents/api/audio-content-theme-api.ts @@ -0,0 +1,15 @@ +import { z } from "zod"; + +import { audioContentThemeSchema } from "@/features/audio-contents/schemas/audio-content-schema"; +import type { AudioContentTheme } from "@/features/audio-contents/schemas/audio-content-schema"; +import type { ApiClient } from "@/shared/api/client"; + +const audioContentThemeListSchema = z.array(audioContentThemeSchema); + +export function getAudioContentThemes(apiClient: ApiClient): Promise { + return apiClient.request({ + path: "/api/v2/admin/ai-characters/audio-content-themes", + responseSchema: audioContentThemeListSchema, + authentication: "required", + }); +} diff --git a/src/features/audio-contents/api/upload-audio-content.ts b/src/features/audio-contents/api/upload-audio-content.ts new file mode 100644 index 0000000..77ac7d0 --- /dev/null +++ b/src/features/audio-contents/api/upload-audio-content.ts @@ -0,0 +1,154 @@ +import type { z } from "zod"; + +import { ApiError, UNKNOWN_API_ERROR_MESSAGE } from "@/shared/api/api-error"; +import { createApiResponseSchema } from "@/shared/api/types"; +import { getRuntimeEnv } from "@/shared/config/env"; +import { authSessionStorage } from "@/features/auth/model/auth-session-storage"; + +type UploadAuthentication = "none" | "required"; + +export type UploadAuthDependencies = { + readonly clearSession: () => void; + readonly getToken: () => string | null; + readonly onAuthExpired: () => void; +}; + +export type UploadAudioContentOptions = { + readonly auth?: UploadAuthDependencies; + readonly authentication: UploadAuthentication; + readonly body: XMLHttpRequestBodyInit; + readonly method: "POST" | "PUT"; + readonly onProgress?: (progress: number) => void; + readonly path: string; + readonly responseSchema: z.ZodType; + readonly signal?: AbortSignal; +}; + +let activeProtectedUploadCount = 0; +let hasHandledAuthenticationExpiry = false; + +function finishProtectedUpload(isProtectedUpload: boolean): void { + if (!isProtectedUpload) { + return; + } + + activeProtectedUploadCount -= 1; + if (activeProtectedUploadCount === 0) { + hasHandledAuthenticationExpiry = false; + } +} + +function createAbortError(): DOMException { + return new DOMException("Upload aborted", "AbortError"); +} + +function readToken(auth: UploadAuthDependencies | undefined): string | null { + if (auth !== undefined) { + return auth.getToken(); + } + + return authSessionStorage.read()?.token ?? null; +} + +function parseErrorResponse(status: number, responseText: string): ApiError { + try { + const parsed: unknown = JSON.parse(responseText); + if (parsed !== null && typeof parsed === "object" && "message" in parsed && "errorProperty" in parsed) { + const message = typeof parsed.message === "string" && parsed.message.trim().length > 0 ? parsed.message : UNKNOWN_API_ERROR_MESSAGE; + const errorProperty = typeof parsed.errorProperty === "string" ? parsed.errorProperty : null; + + return new ApiError({ status, message, errorProperty }); + } + } catch { + return new ApiError({ status, message: UNKNOWN_API_ERROR_MESSAGE, errorProperty: null }); + } + + return new ApiError({ status, message: UNKNOWN_API_ERROR_MESSAGE, errorProperty: null }); +} + +export function uploadAudioContent(options: UploadAudioContentOptions): Promise { + return new Promise((resolve, reject) => { + const isProtectedUpload = options.authentication === "required"; + if (isProtectedUpload) { + activeProtectedUploadCount += 1; + } + + const xhr = new XMLHttpRequest(); + let isSettled = false; + + const finishUpload = (): boolean => { + if (isSettled) { + return false; + } + isSettled = true; + options.signal?.removeEventListener("abort", abort); + finishProtectedUpload(isProtectedUpload); + return true; + }; + const resolveUpload = (data: Data) => { + if (!finishUpload()) { + return; + } + resolve(data); + }; + const rejectUpload = (error: unknown) => { + if (!finishUpload()) { + return; + } + reject(error); + }; + const abort = () => { + xhr.abort(); + rejectUpload(createAbortError()); + }; + + xhr.open(options.method, new URL(options.path, getRuntimeEnv().apiBaseUrl).toString()); + xhr.setRequestHeader("Accept-Language", "ko"); + if (options.authentication === "required") { + const token = readToken(options.auth); + if (token !== null) { + xhr.setRequestHeader("Authorization", `Bearer ${token}`); + } + } + xhr.upload.addEventListener("progress", (event) => { + if (event.lengthComputable) { + options.onProgress?.((event.loaded / event.total) * 100); + } + }); + xhr.onerror = () => rejectUpload(new ApiError({ status: xhr.status, message: UNKNOWN_API_ERROR_MESSAGE, errorProperty: null })); + xhr.onload = () => { + if (xhr.status === 401 && isProtectedUpload && !hasHandledAuthenticationExpiry && readToken(options.auth) !== null) { + hasHandledAuthenticationExpiry = true; + options.auth?.clearSession(); + options.auth?.onAuthExpired(); + } + if (xhr.status >= 200 && xhr.status < 300) { + let parsed: ReturnType>["safeParse"]>; + try { + parsed = createApiResponseSchema(options.responseSchema).safeParse(JSON.parse(xhr.responseText)); + } catch (parseError) { + if (parseError instanceof SyntaxError) { + rejectUpload(new ApiError({ status: xhr.status, message: UNKNOWN_API_ERROR_MESSAGE, errorProperty: null })); + return; + } + + throw parseError; + } + if (parsed.success && parsed.data.success) { + resolveUpload(parsed.data.data); + return; + } + rejectUpload(new ApiError({ status: xhr.status, message: UNKNOWN_API_ERROR_MESSAGE, errorProperty: null })); + return; + } + rejectUpload(parseErrorResponse(xhr.status, xhr.responseText)); + }; + + if (options.signal?.aborted === true) { + abort(); + return; + } + options.signal?.addEventListener("abort", abort, { once: true }); + xhr.send(options.body); + }); +} diff --git a/src/features/audio-contents/components/AudioContentForm.tsx b/src/features/audio-contents/components/AudioContentForm.tsx new file mode 100644 index 0000000..ef365a1 --- /dev/null +++ b/src/features/audio-contents/components/AudioContentForm.tsx @@ -0,0 +1,269 @@ +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, formatPrice, 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 { 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 { FileField } from "@/shared/ui/file-field"; +import { ImageCropDialog } from "@/shared/ui/image-crop-dialog"; +import type { CropSourceImage } from "@/shared/ui/image-crop-dialog"; +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; + + useEffect(() => () => cropSource?.release?.(), [cropSource]); + + function updatePurchaseOption(value: string) { + if (value === "BOTH" || value === "BUY_ONLY" || value === "RENT_ONLY") { + setCreateSettings((current) => ({ ...current, purchaseOption: value })); + } + } + + 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}> + + {errors.title === undefined ? null : } +