feat(ai-character): 캐릭터 관리 기능 구현

This commit is contained in:
Yu Sung
2026-08-01 01:30:28 +09:00
parent a1cae336d9
commit 3550a2dd03
19 changed files with 3308 additions and 0 deletions

View File

@@ -0,0 +1,277 @@
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 { 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 = (
<section className="rounded-lg border border-border bg-card p-4 md:hidden" aria-labelledby="character-edit-mobile-guidance-title">
<h2 className="text-xl font-semibold" id="character-edit-mobile-guidance-title"> .</h2>
<p className="mt-2 text-sm text-muted-foreground">AI 릿 .</p>
</section>
);
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 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);
const [image, setImage] = useState<File | null>(null);
const [cropSource, setCropSource] = useState<CropSourceImage | null>(null);
const [name, setName] = useState(character.name);
const [optionalFields, setOptionalFields] = useState(() => characterOptionalFieldsFromDetail(character));
const [originalWork, setOriginalWork] = useState<OriginalWorkSelection | null>(character.originalWork);
const [systemPrompt, setSystemPrompt] = useState(character.systemPrompt);
const [errors, setErrors] = useState<FieldErrors>({});
const [isImagePreparing, setIsImagePreparing] = useState(false);
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 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;
}
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: {
...toUpdateCharacterOptionalRequest(optionalFields),
description: description.trim(),
name: name.trim(),
originalWorkId: originalWork?.id,
systemPrompt: systemPrompt.trim(),
},
});
navigateTo(routePaths.aiCharacterDetail(String(character.id)), { successNotification: "AI 캐릭터를 저장했습니다." });
} catch (error: unknown) {
setErrors({ form: error instanceof ApiError ? error.message : "AI 캐릭터를 저장하지 못했습니다." });
} finally {
setIsSubmitting(false);
}
}
return (
<UnsavedChangesGuard dirty={dirty} impactDescription="저장하지 않은 변경사항이 사라집니다." title="수정을 취소하시겠습니까?">
{(requestRouteLeave) => (
<section className="flex flex-col gap-4" aria-labelledby="character-edit-title">
<div className="flex flex-col gap-2">
<p className="text-xs font-semibold text-info">AI CHARACTER ADMIN</p>
<h1 className="text-2xl font-bold leading-tight" id="character-edit-title">AI </h1>
<p className="text-sm text-muted-foreground"> . .</p>
</div>
<form className="flex flex-col gap-4 rounded-lg border border-border bg-card p-4" aria-label="AI 캐릭터 수정 form" onSubmit={(event) => void submit(event)} ref={formRef}>
<label className="flex flex-col gap-2 text-sm font-semibold">
<input aria-describedby={errors.name === undefined ? undefined : errorIds.name} aria-invalid={errors.name === undefined ? undefined : true} className="rounded-md border border-input bg-card px-3 py-2 text-base font-normal text-foreground" name="name" onChange={(event) => setName(event.currentTarget.value)} value={name} />
</label>
{errors.name === undefined ? null : <p className="text-sm font-semibold text-destructive" id={errorIds.name} role="alert">{errors.name}</p>}
<label className="flex flex-col gap-2 text-sm font-semibold">
<textarea aria-describedby={errors.systemPrompt === undefined ? undefined : errorIds.systemPrompt} aria-invalid={errors.systemPrompt === undefined ? undefined : true} className="min-h-32 rounded-md border border-input bg-card px-3 py-2 text-base font-normal text-foreground" name="systemPrompt" onChange={(event) => setSystemPrompt(event.currentTarget.value)} value={systemPrompt} />
</label>
{errors.systemPrompt === undefined ? null : <p className="text-sm font-semibold text-destructive" id={errorIds.systemPrompt} role="alert">{errors.systemPrompt}</p>}
<label className="flex flex-col gap-2 text-sm font-semibold">
<textarea aria-describedby={errors.description === undefined ? undefined : errorIds.description} aria-invalid={errors.description === undefined ? undefined : true} className="min-h-24 rounded-md border border-input bg-card px-3 py-2 text-base font-normal text-foreground" name="description" onChange={(event) => setDescription(event.currentTarget.value)} value={description} />
</label>
{errors.description === undefined ? null : <p className="text-sm font-semibold text-destructive" id={errorIds.description} role="alert">{errors.description}</p>}
<CharacterOptionalFields onChange={setOptionalFields} regionEditable={false} value={optionalFields} />
<label className="flex flex-col gap-2 text-sm font-semibold">
<input className="rounded-md border border-input bg-muted px-3 py-2 text-base font-normal text-muted-foreground" disabled name="region" value={character.region} />
</label>
<OriginalWorkSearchField apiClient={apiClient} onChange={setOriginalWork} value={originalWork} />
<FileField accept="image/jpeg,image/png" acceptDescription="선택하지 않으면 기존 프로필 이미지를 유지합니다. JPEG 또는 PNG, 10MB 이하." 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>}
<div className="flex justify-end gap-2">
<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">
{isSubmitting ? "저장 중" : "저장"}
</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={profileCropPolicy}
renderCrop={renderCrop}
/>
)}
</section>
)}
</UnsavedChangesGuard>
);
}
export function CharacterEditPage({ apiClient, characterId, createCropSource = createImageCropSource, renderCrop }: { readonly apiClient: ApiClient; readonly characterId: string; readonly createCropSource?: (file: File) => Promise<CropSourceImage>; readonly renderCrop?: (request: CropRenderRequest) => Promise<File> }) {
const [state, setState] = useState<EditState>({ requestKey: "", status: "loading" });
const [retryKey, setRetryKey] = useState(0);
const requestKey = `${characterId}:${retryKey}`;
useEffect(() => {
let isCurrent = true;
void getCharacter(apiClient, characterId)
.then((character) => {
if (isCurrent) {
setState({ character, requestKey, status: "content" });
}
})
.catch((error: unknown) => {
if (isCurrent) {
setState({ message: error instanceof ApiError ? error.message : "AI 캐릭터 상세를 불러오지 못했습니다.", requestKey, status: "error" });
}
});
return () => {
isCurrent = false;
};
}, [apiClient, characterId, requestKey]);
const visibleState: EditState = state.requestKey === requestKey ? state : { requestKey, status: "loading" };
if (visibleState.status === "loading") {
return <>{mobileEditGuidance}<div className="hidden md:block"><PageState state="loading" title="AI 캐릭터 수정 정보를 불러오는 중" /></div></>;
}
if (visibleState.status === "error") {
return <>{mobileEditGuidance}<div className="hidden md:block"><PageState description={visibleState.message} onRetry={() => setRetryKey((key) => key + 1)} state="error" title="AI 캐릭터 수정 정보 조회 실패" /></div></>;
}
if (!visibleState.character.isActive) {
return <>{mobileEditGuidance}<div className="hidden md:block"><PageState description="상세 화면에서 조회만 가능합니다." state="empty" title="비활성화된 AI 캐릭터는 수정할 수 없습니다." /></div></>;
}
return <>{mobileEditGuidance}<div className="hidden md:block"><CharacterEditForm apiClient={apiClient} character={visibleState.character} createCropSource={createCropSource} renderCrop={renderCrop} /></div></>;
}