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 { TagInput } from "@/shared/ui/tag-input"; 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; readonly genres: readonly SeriesGenreItem[]; readonly mode: "create" | "edit"; readonly renderCrop?: (request: CropRenderRequest) => Promise; readonly series?: SeriesListItem }) { const [cropSource, setCropSource] = useState(null); const [errors, setErrors] = useState({}); const [genreId, setGenreId] = useState(series?.genreId ?? null); const [image, setImage] = useState(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(); const [keyword, setKeyword] = useState(""); const [publishedDays, setPublishedDays] = useState(series?.publishedDaysOfWeek ?? []); const [state, setState] = useState(series?.state ?? "PROCEEDING"); const [studio, setStudio] = useState(series?.studio ?? ""); const [title, setTitle] = useState(series?.title ?? ""); const [writer, setWriter] = useState(series?.writer ?? ""); const formRef = useRef(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 { 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 { 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 { 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 ( {(requestRouteLeave) => (

SERIES

{mode === "create" ? "시리즈 생성" : "시리즈 수정"}

제목, 소개, 장르, 연재 요일과 이미지를 저장 전 운영 기준에 맞게 검토하세요.

void submit(event)} ref={formRef}> void selectImage(file)} value={image} /> {errors.title === undefined ? null : }