diff --git a/src/features/series/api/series-api.ts b/src/features/series/api/series-api.ts new file mode 100644 index 0000000..3bc434a --- /dev/null +++ b/src/features/series/api/series-api.ts @@ -0,0 +1,215 @@ +import { z } from "zod"; + +import type { SeriesContentListItem, SeriesContentSearchItem, SeriesDetail, SeriesGenreItem, SeriesListItem } from "@/features/series/model/types"; +import { seriesContentListResponseSchema, seriesContentSearchResponseSchema, seriesDetailSchema, seriesGenreListResponseSchema, seriesListResponseSchema } from "@/features/series/model/types"; +import { seriesCreateRequestSchema, seriesDeactivateRequestSchema, seriesUpdateRequestSchema } from "@/features/series/schemas/series-schema"; +import type { SeriesCreateRequest, SeriesDeactivateRequest, SeriesUpdateRequest } from "@/features/series/schemas/series-schema"; +import type { ApiClient } from "@/shared/api/client"; +import type { PageData } from "@/shared/api/pagination"; + +export type GetSeriesParams = { + readonly characterId: string; + readonly page?: number; + readonly size?: number; +}; + +export type GetSeriesDetailParams = { + readonly characterId: string; + readonly seriesId: string; +}; + +export type GetSeriesContentsParams = GetSeriesDetailParams & { + readonly page?: number; + readonly size?: number; +}; + +export type SearchUnlinkedSeriesContentsParams = GetSeriesDetailParams & { + readonly searchWord: string; +}; + +export type AddSeriesContentsParams = GetSeriesDetailParams & { + readonly contentIdList: readonly number[]; +}; + +export type RemoveSeriesContentParams = GetSeriesDetailParams & { + readonly contentId: number; +}; + +export type UpdateSeriesOrderParams = { + readonly characterId: string; + readonly ids: readonly number[]; +}; + +export type CreateSeriesParams = { + readonly characterId: string; + readonly image: File; + readonly request: SeriesCreateRequest; +}; + +export type UpdateSeriesParams = GetSeriesDetailParams & { + readonly image?: File; + readonly request: SeriesUpdateRequest; +}; + +export type DeactivateSeriesParams = GetSeriesDetailParams & { + readonly request: SeriesDeactivateRequest; +}; + +export type GetAllSeriesForOrderParams = { + readonly characterId: string; + readonly size?: number; +}; + +export type SeriesOrderCollection = { + readonly isComplete: boolean; + readonly items: readonly SeriesListItem[]; +}; + +const nullSuccessSchema = z.null(); + +function createSeriesFormData(image: File | undefined, request: object): FormData { + const body = new FormData(); + if (image !== undefined) { + body.append("image", image); + } + body.append("request", new Blob([JSON.stringify(request)], { type: "application/json" })); + + return body; +} + +function normalizePage(value: number | undefined): number { + return Math.max(0, Math.trunc(value ?? 0)); +} + +function normalizeSize(value: number | undefined): number { + return Math.max(1, Math.trunc(value ?? 20)); +} + +export async function getSeries(apiClient: ApiClient, params: GetSeriesParams): Promise> { + const page = normalizePage(params.page); + const size = normalizeSize(params.size); + const query = new URLSearchParams({ page: String(page), size: String(size) }); + const response = await apiClient.request({ + path: `/api/v2/admin/ai-characters/${encodeURIComponent(params.characterId)}/series?${query.toString()}`, + responseSchema: seriesListResponseSchema, + authentication: "required", + }); + + return { + totalCount: response.totalCount, + page, + size, + hasNext: (page + 1) * size < response.totalCount, + items: response.items, + }; +} + +export function getSeriesDetail(apiClient: ApiClient, params: GetSeriesDetailParams): Promise { + return apiClient.request({ + path: `/api/v2/admin/ai-characters/${encodeURIComponent(params.characterId)}/series/${encodeURIComponent(params.seriesId)}`, + responseSchema: seriesDetailSchema, + authentication: "required", + }); +} + +export function getSeriesGenres(apiClient: ApiClient): Promise { + return apiClient.request({ + path: "/api/v2/admin/ai-characters/series-genres", + responseSchema: seriesGenreListResponseSchema, + authentication: "required", + }); +} + +export function createSeries(apiClient: ApiClient, params: CreateSeriesParams): Promise { + return apiClient.request({ + path: `/api/v2/admin/ai-characters/${encodeURIComponent(params.characterId)}/series`, + method: "POST", + body: createSeriesFormData(params.image, seriesCreateRequestSchema.parse(params.request)), + responseSchema: nullSuccessSchema, + authentication: "required", + }); +} + +export function updateSeries(apiClient: ApiClient, params: UpdateSeriesParams): Promise { + return apiClient.request({ + path: `/api/v2/admin/ai-characters/${encodeURIComponent(params.characterId)}/series/${encodeURIComponent(params.seriesId)}`, + method: "PUT", + body: createSeriesFormData(params.image, seriesUpdateRequestSchema.parse(params.request)), + responseSchema: nullSuccessSchema, + authentication: "required", + }); +} + +export function deactivateSeries(apiClient: ApiClient, params: GetSeriesDetailParams): Promise { + return apiClient.request({ + path: `/api/v2/admin/ai-characters/${encodeURIComponent(params.characterId)}/series/${encodeURIComponent(params.seriesId)}`, + method: "PUT", + body: createSeriesFormData(undefined, seriesDeactivateRequestSchema.parse({ isActive: false })), + responseSchema: nullSuccessSchema, + authentication: "required", + }); +} + +export async function getSeriesContents(apiClient: ApiClient, params: GetSeriesContentsParams): Promise> { + const page = normalizePage(params.page); + const size = normalizeSize(params.size); + const query = new URLSearchParams({ page: String(page), size: String(size) }); + const response = await apiClient.request({ + path: `/api/v2/admin/ai-characters/${encodeURIComponent(params.characterId)}/series/${encodeURIComponent(params.seriesId)}/contents?${query.toString()}`, + responseSchema: seriesContentListResponseSchema, + authentication: "required", + }); + + return { totalCount: response.totalCount, page, size, hasNext: (page + 1) * size < response.totalCount, items: response.items }; +} + +export function searchUnlinkedSeriesContents(apiClient: ApiClient, params: SearchUnlinkedSeriesContentsParams): Promise { + const query = new URLSearchParams({ search_word: params.searchWord.trim() }); + + return apiClient.request({ + path: `/api/v2/admin/ai-characters/${encodeURIComponent(params.characterId)}/series/${encodeURIComponent(params.seriesId)}/contents/search?${query.toString()}`, + responseSchema: seriesContentSearchResponseSchema, + authentication: "required", + }); +} + +export function addSeriesContents(apiClient: ApiClient, params: AddSeriesContentsParams): Promise { + return apiClient.request({ + path: `/api/v2/admin/ai-characters/${encodeURIComponent(params.characterId)}/series/${encodeURIComponent(params.seriesId)}/contents`, + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ contentIdList: params.contentIdList }), + responseSchema: nullSuccessSchema, + authentication: "required", + }); +} + +export function removeSeriesContent(apiClient: ApiClient, params: RemoveSeriesContentParams): Promise { + return apiClient.request({ + path: `/api/v2/admin/ai-characters/${encodeURIComponent(params.characterId)}/series/${encodeURIComponent(params.seriesId)}/contents/${params.contentId}`, + method: "DELETE", + responseSchema: nullSuccessSchema, + authentication: "required", + }); +} + +export async function getAllSeriesForOrder(apiClient: ApiClient, params: GetAllSeriesForOrderParams): Promise { + const size = normalizeSize(params.size); + const firstPage = await getSeries(apiClient, { characterId: params.characterId, page: 0, size }); + const pageCount = Math.ceil(firstPage.totalCount / size); + const restPages = await Promise.all(Array.from({ length: Math.max(0, pageCount - 1) }, (_, index) => getSeries(apiClient, { characterId: params.characterId, page: index + 1, size }))); + const items = [firstPage, ...restPages].flatMap((page) => page.items); + + return { isComplete: items.length === firstPage.totalCount, items }; +} + +export function updateSeriesOrder(apiClient: ApiClient, params: UpdateSeriesOrderParams): Promise { + return apiClient.request({ + path: `/api/v2/admin/ai-characters/${encodeURIComponent(params.characterId)}/series/orders`, + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ ids: params.ids }), + responseSchema: nullSuccessSchema, + authentication: "required", + }); +} diff --git a/src/features/series/components/GenreCombobox.tsx b/src/features/series/components/GenreCombobox.tsx new file mode 100644 index 0000000..91fcca4 --- /dev/null +++ b/src/features/series/components/GenreCombobox.tsx @@ -0,0 +1,14 @@ +import type { SeriesGenreItem } from "@/features/series/model/types"; + +export function GenreCombobox({ error, errorId, genres, onChange, value }: { readonly error?: string; readonly errorId?: string; readonly genres: readonly SeriesGenreItem[]; readonly onChange: (value: number | null) => void; readonly value: number | null }) { + return ( + + ); +} diff --git a/src/features/series/components/PublishedDaysField.tsx b/src/features/series/components/PublishedDaysField.tsx new file mode 100644 index 0000000..10fc5fb --- /dev/null +++ b/src/features/series/components/PublishedDaysField.tsx @@ -0,0 +1,37 @@ +import type { SeriesPublishedDay } from "@/features/series/schemas/series-schema"; + +const publishedDayOptions = [ + ["SUN", "일요일"], + ["MON", "월요일"], + ["TUE", "화요일"], + ["WED", "수요일"], + ["THU", "목요일"], + ["FRI", "금요일"], + ["SAT", "토요일"], + ["RANDOM", "랜덤"], +] as const satisfies readonly (readonly [SeriesPublishedDay, string])[]; + +export function PublishedDaysField({ error, errorId, onChange, value }: { readonly error?: string; readonly errorId?: string; readonly onChange: (value: readonly SeriesPublishedDay[]) => void; readonly value: readonly SeriesPublishedDay[] }) { + function toggle(day: SeriesPublishedDay): void { + if (value.includes(day)) { + onChange(value.filter((item) => item !== day)); + return; + } + onChange(day === "RANDOM" ? ["RANDOM"] : [...value.filter((item) => item !== "RANDOM"), day]); + } + + return ( +
+ 연재 요일 +
+ {publishedDayOptions.map(([day, label]) => ( + + ))} +
+ {error === undefined ? null : } +
+ ); +} diff --git a/src/features/series/components/SeriesContents.tsx b/src/features/series/components/SeriesContents.tsx new file mode 100644 index 0000000..e8a1b62 --- /dev/null +++ b/src/features/series/components/SeriesContents.tsx @@ -0,0 +1,199 @@ +import { type KeyboardEvent, useCallback, useEffect, useMemo, useRef, useState } from "react"; + +import { addSeriesContents, getSeriesContents, removeSeriesContent, searchUnlinkedSeriesContents } from "@/features/series/api/series-api"; +import type { SeriesContentListItem, SeriesContentSearchItem } from "@/features/series/model/types"; +import { ApiError } from "@/shared/api/api-error"; +import type { ApiClient } from "@/shared/api/client"; +import type { PageData } from "@/shared/api/pagination"; +import { PageState } from "@/shared/ui/page-state"; +import { ResourcePagination } from "@/shared/ui/resource-pagination"; +import { SearchToolbar } from "@/shared/ui/search-toolbar"; +import { useModalFocus } from "@/shared/ui/use-modal-focus"; + +type ContentsState = + | { readonly status: "loading" } + | { readonly data: PageData; readonly status: "content" } + | { readonly message: string; readonly status: "error" }; + +const mutationErrorMessage = "변경사항을 저장하지 못했습니다. 목록을 다시 불러온 뒤 재시도하세요."; + +export function SeriesContents({ apiClient, canManageSeries, characterId, seriesId }: { readonly apiClient: ApiClient; readonly canManageSeries: boolean; readonly characterId: string; readonly seriesId: string }) { + const [page, setPage] = useState(0); + const [size, setSize] = useState(20); + const [retryKey, setRetryKey] = useState(0); + const [state, setState] = useState({ status: "loading" }); + const [search, setSearch] = useState(""); + const [candidates, setCandidates] = useState([]); + const [selectedIds, setSelectedIds] = useState([]); + const [target, setTarget] = useState(null); + const [mutationError, setMutationError] = useState(null); + const [isLinking, setIsLinking] = useState(false); + const [isUnlinking, setIsUnlinking] = useState(false); + const isLinkingRef = useRef(false); + const isUnlinkingRef = useRef(false); + + const loadContents = useCallback(() => { + void getSeriesContents(apiClient, { characterId, seriesId, page, size }) + .then((data) => setState({ data, status: "content" })) + .catch((error: unknown) => setState({ message: error instanceof ApiError ? error.message : "연결된 오디오를 불러오지 못했습니다.", status: "error" })); + }, [apiClient, characterId, page, seriesId, size]); + + useEffect(() => loadContents(), [loadContents, retryKey]); + + useEffect(() => { + if (!canManageSeries) { + return undefined; + } + const query = search.trim(); + if (query.length === 0) { + return; + } + let isCurrent = true; + void searchUnlinkedSeriesContents(apiClient, { characterId, seriesId, searchWord: query }) + .then((items) => { + if (isCurrent) { + setCandidates(items); + } + }) + .catch(() => { + if (isCurrent) { + setCandidates([]); + } + }); + + return () => { + isCurrent = false; + }; + }, [apiClient, canManageSeries, characterId, search, seriesId]); + + const linkedIds = useMemo(() => new Set(state.status === "content" ? state.data.items.map((item) => item.contentId) : []), [state]); + const visibleCandidates = candidates.filter((item) => !linkedIds.has(item.contentId) && !selectedIds.includes(item.contentId)); + + function toggleSelected(contentId: number): void { + setSelectedIds((ids) => (ids.includes(contentId) ? ids.filter((id) => id !== contentId) : [...ids, contentId])); + } + + async function linkSelected(): Promise { + if (isLinkingRef.current) { + return; + } + isLinkingRef.current = true; + setIsLinking(true); + setMutationError(null); + try { + await addSeriesContents(apiClient, { characterId, seriesId, contentIdList: selectedIds }); + setSelectedIds([]); + setSearch(""); + setCandidates([]); + loadContents(); + } catch { + setMutationError(mutationErrorMessage); + } finally { + isLinkingRef.current = false; + setIsLinking(false); + } + } + + async function unlinkTarget(): Promise { + if (target === null || isUnlinkingRef.current) { + return; + } + isUnlinkingRef.current = true; + setIsUnlinking(true); + setMutationError(null); + try { + await removeSeriesContent(apiClient, { characterId, seriesId, contentId: target.contentId }); + setTarget(null); + loadContents(); + } catch { + setTarget(null); + setMutationError(mutationErrorMessage); + } finally { + isUnlinkingRef.current = false; + setIsUnlinking(false); + } + } + + function handleSearchChange(nextSearch: string): void { + setSearch(nextSearch); + setSelectedIds([]); + if (nextSearch.trim().length === 0) { + setCandidates([]); + } + } + + return ( +
+
+

연결된 오디오

+

{canManageSeries ? "현재 시리즈에 연결된 오디오를 조회하고 후보를 검색해 연결합니다." : "현재 시리즈에 연결된 오디오를 조회합니다."}

+
+ {mutationError === null ? null :

{mutationError}

} + {isLinking ?

오디오를 연결하는 중

: null} + {state.status === "loading" ? : null} + {state.status === "error" ? setRetryKey((key) => key + 1)} state="error" title="연결된 오디오 조회 실패" /> : null} + {state.status === "content" ? ( + <> + {state.data.items.length === 0 ? : null} + {state.data.items.length > 0 ? : null} + { setPage(0); setSize(nextSize); }} /> + + ) : null} + {canManageSeries ? : null} + {visibleCandidates.length === 0 ? null : ( +
+
+ {visibleCandidates.map((item) => ( + + ))} +
+
+ )} + {canManageSeries ? : null} + setTarget(null)} onConfirm={() => void unlinkTarget()} target={target} /> +
+ ); +} + +function ContentList({ canManageSeries, items, onUnlink }: { readonly canManageSeries: boolean; readonly items: readonly SeriesContentListItem[]; readonly onUnlink: (item: SeriesContentListItem) => void }) { + return ( +
    + {items.map((item) => ( +
  • + {`${item.title} + {item.title}{item.isAdult ? 성인 : null} + {canManageSeries ? : null} +
  • + ))} +
+ ); +} + +function UnlinkDialog({ isPending, onCancel, onConfirm, target }: { readonly isPending: boolean; readonly onCancel: () => void; readonly onConfirm: () => void; readonly target: SeriesContentListItem | null }) { + const { dialogRef, trapFocus } = useModalFocus(target !== null); + + function handleKeyDown(event: KeyboardEvent): void { + trapFocus(event); + if (event.key === "Escape") { + onCancel(); + } + } + + if (target === null) { + return null; + } + + return ( +
+
+

오디오 연결을 해제하시겠습니까?

{target.title}은 시리즈 재생 순서에서 제외됩니다. 오디오 콘텐츠 자체는 삭제되지 않습니다.

+ {isPending ?

오디오 연결을 해제하는 중

: null} +
+
+
+ ); +} diff --git a/src/features/series/components/SeriesForm.tsx b/src/features/series/components/SeriesForm.tsx new file mode 100644 index 0000000..77a98e2 --- /dev/null +++ b/src/features/series/components/SeriesForm.tsx @@ -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; 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}> + + {errors.title === undefined ? null : } +