import { useEffect, useRef, useState } from "react"; import { navigateTo } from "@/app/browser-location"; import { routePaths } from "@/app/route-paths"; import { getCharacter, updateCharacter } from "@/features/characters/api/character-api"; import type { UpdateCharacterParams } from "@/features/characters/api/character-api"; import { CharacterOptionalFields } from "@/features/characters/components/CharacterOptionalFields"; import { characterOptionalFieldsFromDetail, toUpdateCharacterOptionalRequest } from "@/features/characters/components/character-optional-field-serialization"; import { OriginalWorkSearchField } from "@/features/characters/components/OriginalWorkSearchField"; import type { OriginalWorkSelection } from "@/features/characters/components/OriginalWorkSearchField"; import type { CharacterDetail } from "@/features/characters/model/types"; import { validateCharacterImageFile } from "@/features/characters/validation/character-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 { createImageCropSource } from "@/shared/lib/create-image-crop-source"; import { focusFirstInvalidControl } from "@/shared/lib/focus-first-invalid-control"; 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 { PageState } from "@/shared/ui/page-state"; import { UnsavedChangesGuard } from "@/shared/ui/unsaved-changes-guard"; type FieldErrors = { readonly description?: string; readonly form?: string; readonly image?: string; readonly name?: string; readonly systemPrompt?: string; }; type EditState = | { readonly requestKey: string; readonly status: "loading" } | { readonly character: CharacterDetail; readonly requestKey: string; readonly status: "content" } | { readonly message: string; readonly requestKey: string; readonly status: "error" }; const profileCropPolicy = { aspect: 1, maxWidth: 800, noUpscale: true } as const; const imagePreparationErrorMessage = "이미지 미리보기 준비에 실패했습니다."; const errorIds = { description: "character-edit-description-error", name: "character-edit-name-error", systemPrompt: "character-edit-system-prompt-error", }; const mobileEditGuidance = (

데스크톱에서 수정해 주세요.

AI 캐릭터 수정과 파일 업로드 관리는 태블릿 이상 화면에서 이용할 수 있습니다.

); function validateImage(image: File | null): string | undefined { if (image === null) { return undefined; } const result = validateCharacterImageFile(image); if (result.ok) { return undefined; } if (result.reason === "size") { return "프로필 이미지는 10MB 이하만 업로드할 수 있습니다."; } return "JPEG 또는 PNG 파일만 업로드하세요."; } function hasRequestFields(request: UpdateCharacterParams["request"]): boolean { return Object.keys(request).length > 0; } function CharacterEditForm({ apiClient, character, createCropSource, renderCrop }: { readonly apiClient: ApiClient; readonly character: CharacterDetail; readonly createCropSource: (file: File) => Promise; readonly renderCrop?: (request: CropRenderRequest) => Promise }) { const formRef = useRef(null); const [description, setDescription] = useState(character.description); const [image, setImage] = useState(null); const [cropSource, setCropSource] = useState(null); const [name, setName] = useState(character.name); const [optionalFields, setOptionalFields] = useState(() => characterOptionalFieldsFromDetail(character)); const [originalWork, setOriginalWork] = useState(character.originalWork); const [systemPrompt, setSystemPrompt] = useState(character.systemPrompt); const [errors, setErrors] = useState({}); const [isImagePreparing, setIsImagePreparing] = useState(false); const [isSubmitting, setIsSubmitting] = useState(false); const imageSelectionId = useRef(0); const detailPath = routePaths.aiCharacterDetail(String(character.id)); const initialOptionalFields = characterOptionalFieldsFromDetail(character); const initialDescription = character.description.trim(); const initialName = character.name.trim(); const initialSystemPrompt = character.systemPrompt.trim(); const request: UpdateCharacterParams["request"] = { ...toUpdateCharacterOptionalRequest(optionalFields, initialOptionalFields), ...(description.trim() === initialDescription ? {} : { description: description.trim() }), ...(name.trim() === initialName ? {} : { name: name.trim() }), ...(originalWork !== null && originalWork.id !== character.originalWork?.id ? { originalWorkId: originalWork.id } : {}), ...(systemPrompt.trim() === initialSystemPrompt ? {} : { systemPrompt: systemPrompt.trim() }), }; const hasChanges = image !== null || hasRequestFields(request); const dirty = hasChanges || isImagePreparing || cropSource !== null; const imageSubmitBlocked = isImagePreparing || cropSource !== null; useEffect(() => () => cropSource?.release?.(), [cropSource]); async function selectImage(file: File | null) { imageSelectionId.current += 1; const currentSelectionId = imageSelectionId.current; if (file === null) { setImage(null); setCropSource(null); setIsImagePreparing(false); setErrors((current) => ({ ...current, image: undefined })); return; } const imageError = validateImage(file); setImage(null); if (imageError !== undefined) { setCropSource(null); setIsImagePreparing(false); setErrors((current) => ({ ...current, image: imageError })); return; } setIsImagePreparing(true); try { const nextCropSource = await createCropSource(file); if (currentSelectionId !== imageSelectionId.current) { nextCropSource.release?.(); return; } setCropSource(nextCropSource); setErrors((current) => ({ ...current, image: undefined })); } catch (error: unknown) { if (currentSelectionId !== imageSelectionId.current) { return; } if (error instanceof Error) { setCropSource(null); setErrors((current) => ({ ...current, image: imagePreparationErrorMessage })); return; } throw error; } finally { if (currentSelectionId === imageSelectionId.current) { setIsImagePreparing(false); } } } function validate(): FieldErrors { return { description: description.trim().length === 0 ? "설명을 입력하세요." : undefined, image: imageSubmitBlocked ? "이미지 처리가 끝난 뒤 저장하세요." : errors.image ?? validateImage(image), name: name.trim().length === 0 ? "이름을 입력하세요." : undefined, systemPrompt: systemPrompt.trim().length === 0 ? "시스템 프롬프트를 입력하세요." : undefined, }; } async function submit(event: { readonly preventDefault: () => void }) { event.preventDefault(); if (isSubmitting) { return; } if (!hasChanges) { return; } const nextErrors = validate(); setErrors(nextErrors); if (nextErrors.description !== undefined || nextErrors.image !== undefined || nextErrors.name !== undefined || nextErrors.systemPrompt !== undefined) { queueMicrotask(() => focusFirstInvalidControl(formRef.current)); return; } setIsSubmitting(true); try { await updateCharacter(apiClient, String(character.id), { image: image ?? undefined, request, }); navigateTo(routePaths.aiCharacterDetail(String(character.id)), { successNotification: "AI 캐릭터를 저장했습니다." }); } catch (error: unknown) { setErrors({ form: error instanceof ApiError ? error.message : "AI 캐릭터를 저장하지 못했습니다." }); } finally { setIsSubmitting(false); } } return ( {(requestRouteLeave) => (

AI CHARACTER ADMIN

AI 캐릭터 수정

프로필 텍스트와 이미지를 수정합니다. 지역은 생성 후 변경할 수 없습니다.

void submit(event)} ref={formRef}> {errors.name === undefined ? null : }