Files
voiceon-character-admin/src/features/audio-contents/components/AudioContentForm.tsx

273 lines
20 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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, AudioContentUpdateRequest } 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 = <Data>(options: UploadAudioContentOptions<Data>) => Promise<Data>;
type FieldName = "audio" | "cover" | "detail" | "form" | "previewEnd" | "previewStart" | "price" | "releaseDate" | "tags" | "theme" | "title";
type FieldErrors = Partial<Record<FieldName, string>>;
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;
const previewTimePattern = /^\d{2}:\d{2}:\d{2}$/;
function hasUpdateFields(request: AudioContentUpdateRequest): boolean {
return Object.keys(request).length > 0;
}
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<CropSourceImage>; readonly mode: "create" | "edit"; readonly renderCrop?: (request: CropRenderRequest) => Promise<File>; readonly themes: readonly AudioContentTheme[]; readonly uploadAuth?: UploadAuthDependencies; readonly uploadAudioContentRequest?: UploadAudioContentRequest }) {
const [audioFile, setAudioFile] = useState<File | null>(null);
const [createSettings, setCreateSettings] = useState<AudioContentCreateSettings>(defaultAudioContentCreateSettings);
const [coverImage, setCoverImage] = useState<File | null>(null);
const [cropSource, setCropSource] = useState<CropSourceImage | null>(null);
const [detail, setDetail] = useState(audio?.detail ?? "");
const [errors, setErrors] = useState<FieldErrors>({});
const [isDeactivateDialogOpen, setIsDeactivateDialogOpen] = useState(false);
const [isCoverPreparing, setIsCoverPreparing] = useState(false);
const [isDeactivating, setIsDeactivating] = useState(false);
const [deactivateError, setDeactivateError] = useState<string | undefined>();
const [price, setPrice] = useState(createInitialPrice(audio?.price));
const [releaseSchedule, setReleaseSchedule] = useState<ReleaseScheduleValue>({ publishMode: "immediate", releaseDateTime: "" });
const [tags, setTags] = useState(audio?.tag ?? "");
const [themeId, setThemeId] = useState<number | null>(null);
const [title, setTitle] = useState(audio?.title ?? "");
const [uploadState, setUploadState] = useState<UploadState>({ progress: 0, status: "idle" });
const [abortController, setAbortController] = useState<AbortController | null>(null);
const formRef = useRef<HTMLFormElement>(null);
const coverSelectionIdRef = useRef(0);
const isDeactivatingRef = useRef(false);
const isSubmittingRef = useRef(false);
const contentId = audio === undefined ? null : String(audio.contentId);
const parsedEditPrice = parsePrice(price);
const editRequest = mode === "edit" && audio !== undefined && parsedEditPrice !== null ? toUpdateRequest({ audio, detail: detail.trim(), price: parsedEditPrice, tags: tags.trim(), title: title.trim() }) : {};
const editHasChanges = mode === "edit" && (coverImage !== null || hasUpdateFields(editRequest) || (audio !== undefined && parsedEditPrice === null && price !== createInitialPrice(audio.price)));
const createHasChanges = mode === "create" && (title !== "" || detail !== "" || tags !== "" || price !== createInitialPrice(undefined) || audioFile !== null || coverImage !== null || themeId !== null || releaseSchedule.publishMode !== "immediate" || releaseSchedule.releaseDateTime !== "" || createSettings !== defaultAudioContentCreateSettings);
const isDirty = editHasChanges || createHasChanges;
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,
previewEnd: mode === "create" && createSettings.isGeneratePreview && createSettings.previewEndTime !== null && !previewTimePattern.test(createSettings.previewEndTime) ? "미리듣기 시간은 HH:MM:SS 형식으로 입력하세요." : undefined,
previewStart: mode === "create" && createSettings.isGeneratePreview && createSettings.previewStartTime !== null && !previewTimePattern.test(createSettings.previewStartTime) ? "미리듣기 시간은 HH:MM:SS 형식으로 입력하세요." : 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;
}
if (mode === "edit" && !editHasChanges) {
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: editRequest });
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 (
<UnsavedChangesGuard dirty={isDirty} impactDescription="저장하지 않은 오디오 콘텐츠 변경사항이 사라집니다." title="오디오 편집을 취소하시겠습니까?">
{(requestRouteLeave) => (
<section className="flex flex-col gap-4" aria-labelledby="audio-form-title">
<div className="flex flex-col gap-2">
<p className="text-xs font-semibold text-info">AUDIO CONTENTS</p>
<h2 className="text-2xl font-bold leading-tight" id="audio-form-title">{mode === "create" ? "오디오 콘텐츠 생성" : "오디오 콘텐츠 수정"}</h2>
<p className="text-sm text-muted-foreground">, , . .</p>
</div>
<form className="flex flex-col gap-4 rounded-lg border border-border bg-card p-4" aria-label={mode === "create" ? "오디오 콘텐츠 생성 입력 화면" : "오디오 콘텐츠 수정 입력 화면"} noValidate onSubmit={(event) => void submit(event)} ref={formRef}>
<FileField accept="image/jpeg,image/png" acceptDescription="JPEG 또는 PNG, 10MB 이하, 1:1 crop 후 최대 800×800px로 전송합니다." error={errors.cover} label="커버 이미지" onChange={(file) => void selectCoverImage(file)} value={coverImage} />
{mode === "create" ? <FileField accept={AUDIO_FILE_POLICY.allowedMimeTypes.join(",")} acceptDescription="MP3, AAC, M4A, 최대 1,024,000,000 bytes. WAV는 지원하지 않습니다." error={errors.audio} label="오디오 파일" onChange={setAudioFile} value={audioFile} /> : <p className="rounded-lg border border-border bg-muted p-3 text-sm font-semibold text-muted-foreground"> .</p>}
<label className="flex flex-col gap-2 text-sm font-semibold"><input aria-describedby={errors.title === undefined ? undefined : errorIds.title} aria-invalid={errors.title === undefined ? undefined : true} className="rounded-md border border-input bg-card px-3 py-2 text-base font-normal text-foreground" onChange={(event) => setTitle(event.currentTarget.value)} value={title} /></label>
{errors.title === undefined ? null : <p className="text-sm font-semibold text-destructive" id={errorIds.title} role="alert">{errors.title}</p>}
<label className="flex flex-col gap-2 text-sm font-semibold"> <textarea aria-describedby={errors.detail === undefined ? undefined : errorIds.detail} aria-invalid={errors.detail === undefined ? undefined : true} className="min-h-28 rounded-md border border-input bg-card px-3 py-2 text-base font-normal text-foreground" onChange={(event) => setDetail(event.currentTarget.value)} value={detail} /></label>
{errors.detail === undefined ? null : <p className="text-sm font-semibold text-destructive" id={errorIds.detail} role="alert">{errors.detail}</p>}
<TagInput error={errors.tags} errorId={errorIds.tags} label="태그" onChange={setTags} value={tags} />
{mode === "create" ? <AudioContentThemeSelect error={errors.theme} errorId={errorIds.theme} onChange={setThemeId} themes={themes} value={themeId} /> : <p className="rounded-lg border border-border bg-muted p-3 text-sm font-semibold text-muted-foreground">: {audio?.themeStr ?? "-"} ( )</p>}
<CanPriceField error={errors.price} errorId={errorIds.price} onChange={changePrice} value={price} />
{mode === "create" ? <AudioContentCreateOptions isPaid={isPaid} onChange={setCreateSettings} previewEndError={errors.previewEnd} previewStartError={errors.previewStart} value={createSettings} /> : null}
{mode === "create" ? <ReleaseScheduleField error={errors.releaseDate} errorId={errorIds.releaseDate} onChange={setReleaseSchedule} value={releaseSchedule} /> : <p className="rounded-lg border border-border bg-muted p-3 text-sm font-semibold text-muted-foreground">: {audio?.releaseDate == null ? "즉시 공개" : formatSeoulDateTime(audio.releaseDate)} ( )</p>}
<p className="rounded-lg border border-border bg-muted p-3 text-sm font-semibold text-muted-foreground">시리즈: 현재 .</p>
{errors.form === undefined ? null : <p className="text-sm font-semibold text-destructive" role="alert">{errors.form}</p>}
{uploadState.status === "idle" ? null : <UploadProgress fileName={audioFile?.name ?? coverImage?.name} onCancel={abortController === null ? undefined : () => abortController.abort()} onRetry={uploadState.status === "error" ? () => void submitWithCurrentState() : undefined} progress={uploadState.progress} status={uploadState.status} />}
<div className="flex justify-end gap-2">
{mode === "edit" ? <button className="rounded-md border border-input bg-card px-4 py-2 font-semibold hover:bg-accent" onClick={() => setIsDeactivateDialogOpen(true)} type="button"></button> : null}
<button className="rounded-md border border-input bg-card px-4 py-2 font-semibold hover:bg-accent" onClick={(event) => requestRouteLeave(event.currentTarget, () => navigateTo(mode === "create" ? routePaths.aiCharacterAudioContents(characterId) : routePaths.aiCharacterAudioContentDetail(characterId, contentId ?? "")))} type="button"></button>
<button className="rounded-md border border-input bg-primary px-4 py-2 font-semibold text-primary-foreground hover:bg-[var(--button-bg-hover)] active:bg-[var(--button-bg-active)] disabled:cursor-not-allowed disabled:opacity-60" disabled={uploadState.status === "uploading" || isCoverSubmitBlocked || (mode === "edit" && !editHasChanges)} type="submit">{mode === "create" ? "생성" : "저장"}</button>
</div>
</form>
{cropSource === null ? null : <ImageCropDialog image={cropSource} onApply={(file) => { setCoverImage(file); setCropSource(null); setErrors((current) => ({ ...current, cover: undefined })); }} onCancel={() => { setCoverImage(null); setCropSource(null); setErrors((current) => ({ ...current, cover: undefined })); }} open policy={AUDIO_COVER_POLICY} renderCrop={renderCrop} />}
<ConfirmDeactivateDialog confirmLabel="비활성화 확인" errorMessage={deactivateError} impactDescription={`${title || "선택한 오디오 콘텐츠"}의 목록 노출만 중지하며 콘텐츠는 보관됩니다.`} isPending={isDeactivating} onCancel={() => setIsDeactivateDialogOpen(false)} onConfirm={() => void confirmDeactivate()} open={isDeactivateDialogOpen} targetName="오디오 콘텐츠" />
</section>
)}
</UnsavedChangesGuard>
);
}