feat(ai-character): 오디오 콘텐츠 폼 입력 UX 정렬

This commit is contained in:
Yu Sung
2026-08-04 12:20:04 +09:00
parent a213479e8e
commit b62c416fa1
13 changed files with 608 additions and 173 deletions

View File

@@ -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} />}