feat(ai-character): 수정 요청 변경 필드만 전송

This commit is contained in:
Yu Sung
2026-08-06 23:47:09 +09:00
parent 2a04955bd7
commit dcc344764b
18 changed files with 1695 additions and 120 deletions

View File

@@ -3,6 +3,7 @@ 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";
@@ -64,6 +65,10 @@ function validateImage(image: File | null): string | undefined {
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<CropSourceImage>; readonly renderCrop?: (request: CropRenderRequest) => Promise<File> }) {
const formRef = useRef<HTMLFormElement>(null);
const [description, setDescription] = useState(character.description);
@@ -78,7 +83,19 @@ function CharacterEditForm({ apiClient, character, createCropSource, renderCrop
const [isSubmitting, setIsSubmitting] = useState(false);
const imageSelectionId = useRef(0);
const detailPath = routePaths.aiCharacterDetail(String(character.id));
const dirty = image !== null || isImagePreparing || cropSource !== null || description !== character.description || name !== character.name || originalWork?.id !== character.originalWork?.id || systemPrompt !== character.systemPrompt || JSON.stringify(optionalFields) !== JSON.stringify(characterOptionalFieldsFromDetail(character));
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]);
@@ -143,6 +160,9 @@ function CharacterEditForm({ apiClient, character, createCropSource, renderCrop
if (isSubmitting) {
return;
}
if (!hasChanges) {
return;
}
const nextErrors = validate();
setErrors(nextErrors);
@@ -155,13 +175,7 @@ function CharacterEditForm({ apiClient, character, createCropSource, renderCrop
try {
await updateCharacter(apiClient, String(character.id), {
image: image ?? undefined,
request: {
...toUpdateCharacterOptionalRequest(optionalFields),
description: description.trim(),
name: name.trim(),
originalWorkId: originalWork?.id,
systemPrompt: systemPrompt.trim(),
},
request,
});
navigateTo(routePaths.aiCharacterDetail(String(character.id)), { successNotification: "AI 캐릭터를 저장했습니다." });
} catch (error: unknown) {
@@ -208,7 +222,7 @@ function CharacterEditForm({ apiClient, character, createCropSource, renderCrop
<button className="rounded-md border border-input bg-card px-4 py-2 font-semibold hover:bg-accent" onClick={(event) => requestRouteLeave(event.currentTarget, () => navigateTo(detailPath))} 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={isSubmitting || imageSubmitBlocked} type="submit">
<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={isSubmitting || imageSubmitBlocked || !hasChanges} type="submit">
{isSubmitting ? "저장 중" : "저장"}
</button>
</div>