feat(ai-character): 시리즈 관리 기능 구현
This commit is contained in:
246
src/features/series/components/SeriesForm.tsx
Normal file
246
src/features/series/components/SeriesForm.tsx
Normal file
@@ -0,0 +1,246 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
|
||||
import { navigateTo } from "@/app/browser-location";
|
||||
import { routePaths } from "@/app/route-paths";
|
||||
import { createSeries, deactivateSeries, updateSeries } from "@/features/series/api/series-api";
|
||||
import { GenreCombobox } from "@/features/series/components/GenreCombobox";
|
||||
import { PublishedDaysField } from "@/features/series/components/PublishedDaysField";
|
||||
import type { SeriesGenreItem, SeriesListItem } from "@/features/series/model/types";
|
||||
import type { SeriesPublishedDay, SeriesState } from "@/features/series/schemas/series-schema";
|
||||
import { SERIES_IMAGE_POLICY, validateSeriesImageFile } from "@/features/series/validation/series-image-policy";
|
||||
import { ApiError } from "@/shared/api/api-error";
|
||||
import type { ApiClient } from "@/shared/api/client";
|
||||
import type { CropRenderRequest } from "@/shared/lib/crop-image";
|
||||
import { focusFirstInvalidControl } from "@/shared/lib/focus-first-invalid-control";
|
||||
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 { UnsavedChangesGuard } from "@/shared/ui/unsaved-changes-guard";
|
||||
|
||||
type FieldErrors = {
|
||||
readonly days?: string;
|
||||
readonly form?: string;
|
||||
readonly genre?: string;
|
||||
readonly image?: string;
|
||||
readonly introduction?: string;
|
||||
readonly keyword?: string;
|
||||
readonly title?: string;
|
||||
};
|
||||
|
||||
const stateOptions = [
|
||||
["PROCEEDING", "연재중"],
|
||||
["SUSPEND", "휴재중"],
|
||||
["COMPLETE", "완결"],
|
||||
] as const satisfies readonly (readonly [SeriesState, string])[];
|
||||
|
||||
const errorIds = {
|
||||
days: "series-days-error",
|
||||
genre: "series-genre-error",
|
||||
introduction: "series-introduction-error",
|
||||
keyword: "series-keyword-error",
|
||||
title: "series-title-error",
|
||||
} as const;
|
||||
|
||||
function textOrNull(value: string): string | null {
|
||||
const trimmed = value.trim();
|
||||
return trimmed.length === 0 ? null : trimmed;
|
||||
}
|
||||
|
||||
function imageError(image: File | null, mode: "create" | "edit"): string | undefined {
|
||||
if (image === null) {
|
||||
return mode === "create" ? "시리즈 이미지를 선택하세요." : undefined;
|
||||
}
|
||||
const result = validateSeriesImageFile(image);
|
||||
if (result.ok) {
|
||||
return undefined;
|
||||
}
|
||||
if (result.reason === "size") {
|
||||
return "시리즈 이미지는 10MB 이하만 업로드할 수 있습니다.";
|
||||
}
|
||||
return "JPEG 또는 PNG 파일만 업로드하세요.";
|
||||
}
|
||||
|
||||
function dayError(days: readonly SeriesPublishedDay[]): string | undefined {
|
||||
if (days.length === 0) {
|
||||
return "연재 요일을 선택하세요.";
|
||||
}
|
||||
return days.includes("RANDOM") && days.length > 1 ? "랜덤은 단독으로만 선택하세요." : undefined;
|
||||
}
|
||||
|
||||
function editedState(state: SeriesState, series: SeriesListItem | undefined): SeriesState | undefined {
|
||||
return state === (series?.state ?? "PROCEEDING") ? undefined : state;
|
||||
}
|
||||
|
||||
function hasErrors(errors: FieldErrors): boolean {
|
||||
return Object.values(errors).some((error) => error !== undefined);
|
||||
}
|
||||
|
||||
export function SeriesForm({ apiClient, characterId, createCropSource, genres, mode, renderCrop, series }: { readonly apiClient: ApiClient; readonly characterId: string; readonly createCropSource: (file: File) => Promise<CropSourceImage>; readonly genres: readonly SeriesGenreItem[]; readonly mode: "create" | "edit"; readonly renderCrop?: (request: CropRenderRequest) => Promise<File>; readonly series?: SeriesListItem }) {
|
||||
const [cropSource, setCropSource] = useState<CropSourceImage | null>(null);
|
||||
const [errors, setErrors] = useState<FieldErrors>({});
|
||||
const [genreId, setGenreId] = useState<number | null>(series?.genreId ?? null);
|
||||
const [image, setImage] = useState<File | null>(null);
|
||||
const [introduction, setIntroduction] = useState(series?.introduction ?? "");
|
||||
const [isAdult, setIsAdult] = useState(series?.isAdult ?? false);
|
||||
const [isDeactivateDialogOpen, setIsDeactivateDialogOpen] = useState(false);
|
||||
const [isDeactivating, setIsDeactivating] = useState(false);
|
||||
const [isImagePreparing, setIsImagePreparing] = useState(false);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [deactivateError, setDeactivateError] = useState<string | undefined>();
|
||||
const [keyword, setKeyword] = useState("");
|
||||
const [publishedDays, setPublishedDays] = useState<readonly SeriesPublishedDay[]>(series?.publishedDaysOfWeek ?? []);
|
||||
const [state, setState] = useState<SeriesState>(series?.state ?? "PROCEEDING");
|
||||
const [studio, setStudio] = useState(series?.studio ?? "");
|
||||
const [title, setTitle] = useState(series?.title ?? "");
|
||||
const [writer, setWriter] = useState(series?.writer ?? "");
|
||||
const formRef = useRef<HTMLFormElement>(null);
|
||||
const imageSelectionIdRef = useRef(0);
|
||||
const isDeactivatingRef = useRef(false);
|
||||
const isSavingRef = useRef(false);
|
||||
const seriesId = series === undefined ? null : String(series.seriesId);
|
||||
const dirty = image !== null || title !== (series?.title ?? "") || introduction !== (series?.introduction ?? "") || keyword !== "" || genreId !== (series?.genreId ?? null) || isAdult !== (series?.isAdult ?? false) || publishedDays.join(",") !== (series?.publishedDaysOfWeek ?? []).join(",") || state !== (series?.state ?? "PROCEEDING") || studio !== (series?.studio ?? "") || writer !== (series?.writer ?? "");
|
||||
const isImageSubmitBlocked = isImagePreparing || cropSource !== null;
|
||||
|
||||
useEffect(() => () => cropSource?.release?.(), [cropSource]);
|
||||
|
||||
async function selectImage(file: File | null): Promise<void> {
|
||||
const selectionId = imageSelectionIdRef.current + 1;
|
||||
imageSelectionIdRef.current = selectionId;
|
||||
if (file === null) {
|
||||
setImage(null);
|
||||
setCropSource(null);
|
||||
setIsImagePreparing(false);
|
||||
return;
|
||||
}
|
||||
const validation = validateSeriesImageFile(file);
|
||||
if (!validation.ok) {
|
||||
setImage(null);
|
||||
setCropSource(null);
|
||||
setIsImagePreparing(false);
|
||||
setErrors((current) => ({ ...current, image: imageError(file, mode) }));
|
||||
return;
|
||||
}
|
||||
setImage(null);
|
||||
setCropSource(null);
|
||||
setIsImagePreparing(true);
|
||||
setErrors((current) => ({ ...current, image: undefined }));
|
||||
try {
|
||||
const source = await createCropSource(file);
|
||||
if (imageSelectionIdRef.current === selectionId) {
|
||||
setCropSource(source);
|
||||
} else {
|
||||
source.release?.();
|
||||
}
|
||||
} catch {
|
||||
if (imageSelectionIdRef.current === selectionId) {
|
||||
setErrors((current) => ({ ...current, image: "이미지 미리보기 준비에 실패했습니다." }));
|
||||
}
|
||||
} finally {
|
||||
if (imageSelectionIdRef.current === selectionId) {
|
||||
setIsImagePreparing(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function validate(): FieldErrors {
|
||||
return {
|
||||
days: dayError(publishedDays),
|
||||
genre: genreId === null ? "장르를 선택하세요." : undefined,
|
||||
image: isImageSubmitBlocked ? "이미지 처리가 끝난 뒤 저장하세요." : imageError(image, mode),
|
||||
introduction: introduction.trim().length === 0 ? "소개를 입력하세요." : undefined,
|
||||
keyword: mode === "create" && keyword.trim().length === 0 ? "키워드를 입력하세요." : undefined,
|
||||
title: title.trim().length === 0 ? "제목을 입력하세요." : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
async function submit(event: { readonly preventDefault: () => void }): Promise<void> {
|
||||
event.preventDefault();
|
||||
if (isSavingRef.current) {
|
||||
return;
|
||||
}
|
||||
const nextErrors = validate();
|
||||
setErrors(nextErrors);
|
||||
if (hasErrors(nextErrors) || genreId === null) {
|
||||
queueMicrotask(() => focusFirstInvalidControl(formRef.current));
|
||||
return;
|
||||
}
|
||||
isSavingRef.current = true;
|
||||
setIsSaving(true);
|
||||
try {
|
||||
if (mode === "create" && image !== null) {
|
||||
await createSeries(apiClient, { characterId, image, request: { title: title.trim(), introduction: introduction.trim(), publishedDaysOfWeek: [...publishedDays], keyword: keyword.trim(), genreId, isAdult, writer: textOrNull(writer), studio: textOrNull(studio) } });
|
||||
navigateTo(routePaths.aiCharacterSeries(characterId), { successNotification: "시리즈를 생성했습니다." });
|
||||
return;
|
||||
}
|
||||
if (mode === "edit" && seriesId !== null) {
|
||||
await updateSeries(apiClient, { characterId, seriesId, image: image ?? undefined, request: { title: title.trim(), introduction: introduction.trim(), publishedDaysOfWeek: [...publishedDays], genreId, isAdult, state: editedState(state, series), writer: textOrNull(writer), studio: textOrNull(studio) } });
|
||||
navigateTo(routePaths.aiCharacterSeriesDetail(characterId, seriesId), { successNotification: "시리즈를 저장했습니다." });
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
setErrors({ form: error instanceof ApiError ? error.message : "시리즈를 저장하지 못했습니다." });
|
||||
} finally {
|
||||
isSavingRef.current = false;
|
||||
setIsSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function confirmDeactivate(): Promise<void> {
|
||||
if (seriesId === null || isDeactivatingRef.current) {
|
||||
return;
|
||||
}
|
||||
isDeactivatingRef.current = true;
|
||||
setIsDeactivating(true);
|
||||
setDeactivateError(undefined);
|
||||
try {
|
||||
await deactivateSeries(apiClient, { characterId, seriesId });
|
||||
navigateTo(routePaths.aiCharacterSeries(characterId), { successNotification: "시리즈를 비활성화했습니다." });
|
||||
} catch (error: unknown) {
|
||||
if (!(error instanceof Error)) {
|
||||
throw error;
|
||||
}
|
||||
setDeactivateError("비활성화하지 못했습니다. 다시 시도하세요.");
|
||||
} finally {
|
||||
isDeactivatingRef.current = false;
|
||||
setIsDeactivating(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<UnsavedChangesGuard dirty={dirty} impactDescription="저장하지 않은 시리즈 변경사항이 사라집니다." title="시리즈 편집을 취소하시겠습니까?">
|
||||
{(requestRouteLeave) => (
|
||||
<section className="flex flex-col gap-4" aria-labelledby="series-form-title">
|
||||
<div className="flex flex-col gap-2">
|
||||
<p className="text-xs font-semibold text-info">SERIES</p>
|
||||
<h2 className="text-2xl font-bold leading-tight" id="series-form-title">{mode === "create" ? "시리즈 생성" : "시리즈 수정"}</h2>
|
||||
<p className="text-sm text-muted-foreground">제목, 소개, 장르, 연재 요일과 이미지를 저장 전 운영 기준에 맞게 검토하세요.</p>
|
||||
</div>
|
||||
<form aria-label={mode === "create" ? "시리즈 생성 입력 화면" : "시리즈 수정 입력 화면"} className="flex flex-col gap-4 rounded-lg border border-border bg-card p-4" 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.introduction === undefined ? undefined : errorIds.introduction} aria-invalid={errors.introduction === 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) => setIntroduction(event.currentTarget.value)} value={introduction} /></label>
|
||||
{errors.introduction === undefined ? null : <p className="text-sm font-semibold text-destructive" id={errorIds.introduction} role="alert">{errors.introduction}</p>}
|
||||
{mode === "create" ? <label className="flex flex-col gap-2 text-sm font-semibold">키워드<input aria-describedby={errors.keyword === undefined ? undefined : errorIds.keyword} aria-invalid={errors.keyword === undefined ? undefined : true} className="rounded-md border border-input bg-card px-3 py-2 text-base font-normal text-foreground" onChange={(event) => setKeyword(event.currentTarget.value)} value={keyword} /></label> : null}
|
||||
{errors.keyword === undefined ? null : <p className="text-sm font-semibold text-destructive" id={errorIds.keyword} role="alert">{errors.keyword}</p>}
|
||||
<GenreCombobox error={errors.genre} errorId={errorIds.genre} genres={genres} onChange={setGenreId} value={genreId} />
|
||||
<PublishedDaysField error={errors.days} errorId={errorIds.days} onChange={setPublishedDays} value={publishedDays} />
|
||||
{mode === "edit" ? <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) => setState(event.currentTarget.value as SeriesState)} value={state}>{stateOptions.map(([value, label]) => <option key={value} value={value}>{label}</option>)}</select></label> : null}
|
||||
<label className="flex items-center gap-2 text-sm font-semibold"><input checked={isAdult} onChange={(event) => setIsAdult(event.currentTarget.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) => setWriter(event.currentTarget.value)} value={writer} /></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) => setStudio(event.currentTarget.value)} value={studio} /></label>
|
||||
<FileField accept="image/jpeg,image/png" acceptDescription="JPEG 또는 PNG, 10MB 이하, 210:297 crop 후 최대 폭 1,000px로 전송합니다." error={errors.image} label="시리즈 이미지" onChange={(file) => void selectImage(file)} value={image} />
|
||||
{errors.form === undefined ? null : <p className="text-sm font-semibold text-destructive" role="alert">{errors.form}</p>}
|
||||
{isSaving ? <p className="rounded-md border border-border bg-muted p-3 text-sm font-semibold" role="status">시리즈를 저장하는 중</p> : null}
|
||||
<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.aiCharacterSeries(characterId) : routePaths.aiCharacterSeriesDetail(characterId, seriesId ?? "")))} 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:opacity-60" disabled={isSaving || isImageSubmitBlocked} type="submit">{mode === "create" ? "생성" : "저장"}</button>
|
||||
</div>
|
||||
</form>
|
||||
{cropSource === null ? null : <ImageCropDialog image={cropSource} onApply={(file) => { setImage(file); setCropSource(null); setErrors((current) => ({ ...current, image: undefined })); }} onCancel={() => { setImage(null); setCropSource(null); setErrors((current) => ({ ...current, image: undefined })); }} open policy={SERIES_IMAGE_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