feat(ai-character): 캐릭터 관리 기능 구현
This commit is contained in:
277
src/features/characters/pages/CharacterEditPage.tsx
Normal file
277
src/features/characters/pages/CharacterEditPage.tsx
Normal 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></>;
|
||||
}
|
||||
Reference in New Issue
Block a user