feat(ai-character): 오디오 콘텐츠 관리 기능 구현
This commit is contained in:
269
src/features/audio-contents/components/AudioContentForm.tsx
Normal file
269
src/features/audio-contents/components/AudioContentForm.tsx
Normal file
@@ -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 = <Data>(options: UploadAudioContentOptions<Data>) => Promise<Data>;
|
||||
|
||||
type FieldName = "audio" | "cover" | "detail" | "form" | "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;
|
||||
|
||||
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 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 (
|
||||
<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" ? "오디오 콘텐츠 생성 입력 화면" : "오디오 콘텐츠 수정 입력 화면"} onSubmit={(event) => void submit(event)} ref={formRef}>
|
||||
<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>}
|
||||
<label className="flex flex-col gap-2 text-sm font-semibold">태그<input aria-describedby={errors.tags === undefined ? undefined : errorIds.tags} aria-invalid={errors.tags === undefined ? undefined : true} className="rounded-md border border-input bg-card px-3 py-2 text-base font-normal text-foreground" onChange={(event) => setTags(event.currentTarget.value)} value={tags} /></label>
|
||||
{errors.tags === undefined ? null : <p className="text-sm font-semibold text-destructive" id={errorIds.tags} role="alert">{errors.tags}</p>}
|
||||
<label className="flex flex-col gap-2 text-sm font-semibold">가격<input aria-describedby={errors.price === undefined ? undefined : errorIds.price} aria-invalid={errors.price === undefined ? undefined : true} className="rounded-md border border-input bg-card px-3 py-2 text-base font-normal text-foreground" inputMode="numeric" onChange={(event) => setPrice(formatPrice(event.currentTarget.value))} value={price} /></label>
|
||||
{errors.price === undefined ? null : <p className="text-sm font-semibold text-destructive" id={errorIds.price} role="alert">{errors.price}</p>}
|
||||
{mode === "create" ? (
|
||||
<fieldset className="grid gap-3 rounded-lg border border-border bg-card p-4 sm:grid-cols-2">
|
||||
<legend className="text-sm font-semibold">생성 옵션</legend>
|
||||
<label className="flex flex-col gap-2 text-sm font-semibold">구매 옵션<select aria-label="구매 옵션" className="rounded-md border border-input bg-card px-3 py-2 text-base font-normal text-foreground" onChange={(event) => updatePurchaseOption(event.currentTarget.value)} value={createSettings.purchaseOption}><option value="BOTH">구매/대여</option><option value="BUY_ONLY">구매 전용</option><option value="RENT_ONLY">대여 전용</option></select></label>
|
||||
<label className="flex items-center gap-2 text-sm font-semibold"><input checked={createSettings.limited !== null} onChange={(event) => { const checked = event.currentTarget.checked; setCreateSettings((current) => ({ ...current, limited: checked ? 1 : null })); }} type="checkbox" />기간제</label>
|
||||
<label className="flex items-center gap-2 text-sm font-semibold"><input checked={createSettings.isAdult} onChange={(event) => { const checked = event.currentTarget.checked; setCreateSettings((current) => ({ ...current, isAdult: checked })); }} type="checkbox" />성인 콘텐츠</label>
|
||||
<label className="flex items-center gap-2 text-sm font-semibold"><input checked={createSettings.isGeneratePreview} onChange={(event) => { const checked = event.currentTarget.checked; setCreateSettings((current) => ({ ...current, isGeneratePreview: checked })); }} type="checkbox" />미리듣기 생성</label>
|
||||
<label className="flex items-center gap-2 text-sm font-semibold"><input checked={createSettings.isOnlyRental} onChange={(event) => { const checked = event.currentTarget.checked; setCreateSettings((current) => ({ ...current, isOnlyRental: checked })); }} type="checkbox" />대여 전용</label>
|
||||
<label className="flex items-center gap-2 text-sm font-semibold"><input checked={createSettings.isPointAvailable} onChange={(event) => { const checked = event.currentTarget.checked; setCreateSettings((current) => ({ ...current, isPointAvailable: checked })); }} type="checkbox" />포인트 사용</label>
|
||||
<label className="flex items-center gap-2 text-sm font-semibold"><input checked={createSettings.isCommentAvailable} onChange={(event) => { const checked = event.currentTarget.checked; setCreateSettings((current) => ({ ...current, isCommentAvailable: checked })); }} type="checkbox" />댓글 허용</label>
|
||||
<label className="flex items-center gap-2 text-sm font-semibold"><input checked={createSettings.isFullDetailVisible} onChange={(event) => { const checked = event.currentTarget.checked; setCreateSettings((current) => ({ ...current, isFullDetailVisible: checked })); }} type="checkbox" />상세 정보 전체 공개</label>
|
||||
<label className="flex flex-col gap-2 text-sm font-semibold">미리듣기 시작<input className="rounded-md border border-input bg-card px-3 py-2 text-base font-normal text-foreground" onChange={(event) => { const value = event.currentTarget.value; setCreateSettings((current) => ({ ...current, previewStartTime: value.length === 0 ? null : value })); }} value={createSettings.previewStartTime ?? ""} /></label>
|
||||
<label className="flex flex-col gap-2 text-sm font-semibold">미리듣기 종료<input className="rounded-md border border-input bg-card px-3 py-2 text-base font-normal text-foreground" onChange={(event) => { const value = event.currentTarget.value; setCreateSettings((current) => ({ ...current, previewEndTime: value.length === 0 ? null : value })); }} value={createSettings.previewEndTime ?? ""} /></label>
|
||||
<label className="flex flex-col gap-2 text-sm font-semibold">언어 코드<input className="rounded-md border border-input bg-card px-3 py-2 text-base font-normal text-foreground" onChange={(event) => { const value = event.currentTarget.value; setCreateSettings((current) => ({ ...current, languageCode: value.length === 0 ? null : value })); }} value={createSettings.languageCode ?? ""} /></label>
|
||||
</fieldset>
|
||||
) : null}
|
||||
{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>}
|
||||
{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>}
|
||||
{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>}
|
||||
<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} />
|
||||
<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} 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user