feat(ai-character): 오디오 콘텐츠 폼 입력 UX 정렬
This commit is contained in:
@@ -7,6 +7,8 @@ import { uploadAudioContent } from "@/features/audio-contents/api/upload-audio-c
|
||||
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 { AudioContentCreateOptions } from "@/features/audio-contents/components/AudioContentCreateOptions";
|
||||
import { AudioTagInput } from "@/features/audio-contents/components/AudioTagInput";
|
||||
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";
|
||||
@@ -73,12 +75,15 @@ export function AudioContentForm({ apiClient, audio, characterId, createCropSour
|
||||
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 updatePurchaseOption(value: string) {
|
||||
if (value === "BOTH" || value === "BUY_ONLY" || value === "RENT_ONLY") {
|
||||
setCreateSettings((current) => ({ ...current, purchaseOption: value }));
|
||||
function changePrice(value: string) {
|
||||
const nextPrice = formatPrice(value);
|
||||
setPrice(nextPrice);
|
||||
if (mode === "create" && nextPrice === "0") {
|
||||
setCreateSettings((current) => ({ ...current, purchaseOption: "BOTH", isGeneratePreview: false, isPointAvailable: false, previewStartTime: null, previewEndTime: null }));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -223,34 +228,18 @@ export function AudioContentForm({ apiClient, audio, characterId, createCropSour
|
||||
<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}>
|
||||
<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>}
|
||||
<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}
|
||||
<AudioTagInput error={errors.tags} errorId={errorIds.tags} 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>}
|
||||
<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" min="0" onChange={(event) => changePrice(event.currentTarget.value)} step="1" type="number" value={price} /></label><p className="text-sm text-muted-foreground">단위: 캔</p>
|
||||
{errors.price === undefined ? null : <p className="text-sm font-semibold text-destructive" id={errorIds.price} role="alert">{errors.price}</p>}
|
||||
{mode === "create" ? <AudioContentCreateOptions isPaid={isPaid} onChange={setCreateSettings} 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>}
|
||||
{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} />}
|
||||
|
||||
Reference in New Issue
Block a user