diff --git a/src/features/characters/api/character-api.ts b/src/features/characters/api/character-api.ts new file mode 100644 index 0000000..a2588ac --- /dev/null +++ b/src/features/characters/api/character-api.ts @@ -0,0 +1,197 @@ +import type { CharacterDetail, CharacterListItem, OriginalWorkSearchItem } from "@/features/characters/model/types"; +import { characterDetailSchema, characterListResponseSchema, originalWorkSearchItemSchema } from "@/features/characters/model/types"; +import type { ApiClient } from "@/shared/api/client"; +import type { PageData } from "@/shared/api/pagination"; +import { z } from "zod"; + +export type GetCharactersParams = { + readonly page?: number; + readonly searchTerm?: string; + readonly size?: number; +}; + +type CharacterRelationshipRequest = { + readonly personName: string; + readonly relationshipName: string; + readonly description: string; + readonly importance: number; + readonly relationshipType: string; + readonly currentStatus: string; +}; + +type CharacterTraitRequest = { + readonly trait: string; + readonly description: string; +}; + +type CharacterBackgroundRequest = { + readonly topic: string; + readonly description: string; +}; + +type CharacterMemoryRequest = { + readonly title: string; + readonly content: string; + readonly emotion: string; +}; + +type CharacterRepeatedRequest = { + readonly tags?: readonly string[]; + readonly hobbies?: readonly string[]; + readonly values?: readonly string[]; + readonly goals?: readonly string[]; + readonly relationships?: readonly CharacterRelationshipRequest[]; + readonly personalities?: readonly CharacterTraitRequest[]; + readonly backgrounds?: readonly CharacterBackgroundRequest[]; + readonly memories?: readonly CharacterMemoryRequest[]; +}; + +type CharacterUpdateRepeatedRequest = { + readonly tags?: readonly string[] | null; + readonly hobbies?: readonly string[] | null; + readonly values?: readonly string[] | null; + readonly goals?: readonly string[] | null; + readonly relationships?: readonly CharacterRelationshipRequest[] | null; + readonly personalities?: readonly CharacterTraitRequest[] | null; + readonly backgrounds?: readonly CharacterBackgroundRequest[] | null; + readonly memories?: readonly CharacterMemoryRequest[] | null; +}; + +type CharacterEditableRequest = { + readonly name?: string; + readonly systemPrompt?: string; + readonly description?: string; + readonly age?: string | null; + readonly gender?: string | null; + readonly mbti?: string | null; + readonly speechPattern?: string | null; + readonly speechStyle?: string | null; + readonly appearance?: string | null; + readonly originalTitle?: string | null; + readonly originalLink?: string | null; + readonly originalWorkId?: number | null; + readonly characterType?: string | null; +}; + +export type CreateCharacterParams = { + readonly image: File; + readonly request: CharacterEditableRequest & CharacterRepeatedRequest & { + readonly name: string; + readonly systemPrompt: string; + readonly description: string; + readonly region?: string; + }; +}; + +export type UpdateCharacterParams = { + readonly image?: File; + readonly request: CharacterEditableRequest & CharacterUpdateRepeatedRequest; +}; + +const nullSuccessSchema = z.null(); +const originalWorkSearchResponseSchema = z.array(originalWorkSearchItemSchema); + +function hasClearedOriginalWork(request: object): request is { readonly originalWorkId: null } { + return "originalWorkId" in request && request.originalWorkId === null; +} + +function serializeCharacterRequest(request: object): object { + if (!hasClearedOriginalWork(request)) { + return request; + } + + return Object.fromEntries(Object.entries(request).filter(([key]) => key !== "originalWorkId")); +} + +function createCharacterFormData(image: File | undefined, request: object): FormData { + const body = new FormData(); + if (image !== undefined) { + body.append("image", image); + } + body.append("request", new Blob([JSON.stringify(serializeCharacterRequest(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 getCharacters(apiClient: ApiClient, params: GetCharactersParams): Promise> { + const page = normalizePage(params.page); + const size = normalizeSize(params.size); + const query = new URLSearchParams({ page: String(page), size: String(size) }); + const searchTerm = params.searchTerm?.trim(); + if (searchTerm !== undefined && searchTerm.length > 0) { + query.set("searchTerm", searchTerm); + } + const response = await apiClient.request({ + path: `/api/v2/admin/ai-characters?${query.toString()}`, + responseSchema: characterListResponseSchema, + authentication: "required", + }); + + return { + totalCount: response.totalCount, + page, + size, + hasNext: (page + 1) * size < response.totalCount, + items: response.content, + }; +} + +export function getCharacter(apiClient: ApiClient, characterId: string): Promise { + return apiClient.request({ + path: `/api/v2/admin/ai-characters/${encodeURIComponent(characterId)}`, + responseSchema: characterDetailSchema, + authentication: "required", + }); +} + +export function searchOriginalWorks(apiClient: ApiClient, searchTerm: string): Promise { + const query = new URLSearchParams({ searchTerm: searchTerm.trim() }); + + return apiClient.request({ + path: `/api/v2/admin/ai-characters/original-works/search?${query.toString()}`, + responseSchema: originalWorkSearchResponseSchema, + authentication: "required", + }); +} + +export async function createCharacter(apiClient: ApiClient, params: CreateCharacterParams): Promise { + await apiClient.request({ + path: "/api/v2/admin/ai-characters", + method: "POST", + body: createCharacterFormData(params.image, params.request), + responseSchema: nullSuccessSchema, + authentication: "required", + }); +} + +export async function updateCharacter( + apiClient: ApiClient, + characterId: string, + params: UpdateCharacterParams, +): Promise { + await apiClient.request({ + path: `/api/v2/admin/ai-characters/${encodeURIComponent(characterId)}`, + method: "PUT", + body: createCharacterFormData(params.image, params.request), + responseSchema: nullSuccessSchema, + authentication: "required", + }); +} + +export async function deactivateCharacter(apiClient: ApiClient, characterId: string): Promise { + await apiClient.request({ + path: `/api/v2/admin/ai-characters/${encodeURIComponent(characterId)}`, + method: "PUT", + body: createCharacterFormData(undefined, { isActive: false }), + responseSchema: nullSuccessSchema, + authentication: "required", + }); +} diff --git a/src/features/characters/components/CharacterList.tsx b/src/features/characters/components/CharacterList.tsx new file mode 100644 index 0000000..a8d6f7c --- /dev/null +++ b/src/features/characters/components/CharacterList.tsx @@ -0,0 +1,35 @@ +import { CharacterListItem } from "@/features/characters/components/CharacterListItem"; +import type { CharacterListItem as CharacterListItemData } from "@/features/characters/model/types"; +import type { PageData } from "@/shared/api/pagination"; +import { ResponsiveResourceList } from "@/shared/ui/responsive-resource-list"; + +export function CharacterList({ data }: { readonly data: PageData }) { + const desktop = ( + + + + + + + + + + + {data.items.map((character) => ( + + + + + + + ))} + +
캐릭터지역태그선택
+

{character.name}

+

{character.description}

+
{character.region}{character.tags.join(", ")}
+ ); + const mobile =
{data.items.map((character) => )}
; + + return ; +} diff --git a/src/features/characters/components/CharacterListItem.tsx b/src/features/characters/components/CharacterListItem.tsx new file mode 100644 index 0000000..b20550b --- /dev/null +++ b/src/features/characters/components/CharacterListItem.tsx @@ -0,0 +1,36 @@ +import { navigateTo } from "@/app/browser-location"; +import { routePaths } from "@/app/route-paths"; +import type { CharacterListItem as CharacterListItemData } from "@/features/characters/model/types"; + +export function CharacterListItem({ character }: { readonly character: CharacterListItemData }) { + const detailPath = routePaths.aiCharacterDetail(String(character.id)); + const description = character.description.length > 0 ? character.description : "설명이 없습니다."; + + return ( + { + event.preventDefault(); + navigateTo(detailPath); + }} + > + + {character.imageUrl === null ? ( + + ) : ( + + )} + + {character.name} + {description} + ID {character.id} · {character.region} + {character.tags.join(", ")} + + + + ); +} diff --git a/src/features/characters/components/CharacterOptionalFields.tsx b/src/features/characters/components/CharacterOptionalFields.tsx new file mode 100644 index 0000000..ce1fb25 --- /dev/null +++ b/src/features/characters/components/CharacterOptionalFields.tsx @@ -0,0 +1,145 @@ +export type RelationshipDraft = { + readonly personName: string; + readonly relationshipName: string; + readonly description: string; + readonly importance: string; + readonly relationshipType: string; + readonly currentStatus: string; +}; + +export type TraitDraft = { + readonly trait: string; + readonly description: string; +}; + +export type BackgroundDraft = { + readonly topic: string; + readonly description: string; +}; + +export type MemoryDraft = { + readonly title: string; + readonly content: string; + readonly emotion: string; +}; + +export type CharacterOptionalFieldsValue = { + readonly age: string; + readonly gender: string; + readonly mbti: string; + readonly speechPattern: string; + readonly speechStyle: string; + readonly appearance: string; + readonly region: string; + readonly originalTitle: string; + readonly originalLink: string; + readonly originalTitleTouched: boolean; + readonly originalLinkTouched: boolean; + readonly characterType: string; + readonly tags: string; + readonly hobbies: string; + readonly values: string; + readonly goals: string; + readonly relationships: readonly RelationshipDraft[]; + readonly personalities: readonly TraitDraft[]; + readonly backgrounds: readonly BackgroundDraft[]; + readonly memories: readonly MemoryDraft[]; +}; + +type ScalarKey = "age" | "gender" | "mbti" | "speechPattern" | "speechStyle" | "appearance" | "region" | "originalTitle" | "originalLink" | "characterType"; +type StringArrayKey = "tags" | "hobbies" | "values" | "goals"; + +const scalarFields = [ + { key: "age", label: "나이" }, + { key: "gender", label: "성별" }, + { key: "mbti", label: "MBTI" }, + { key: "speechPattern", label: "말투 패턴" }, + { key: "speechStyle", label: "말투 스타일" }, + { key: "appearance", label: "외형" }, + { key: "originalTitle", label: "원작 제목" }, + { key: "originalLink", label: "원작 링크" }, + { key: "characterType", label: "캐릭터 유형" }, +] as const satisfies readonly { readonly key: ScalarKey; readonly label: string }[]; + +const stringArrayFields = [ + { key: "tags", label: "태그" }, + { key: "hobbies", label: "취미" }, + { key: "values", label: "가치관" }, + { key: "goals", label: "목표" }, +] as const satisfies readonly { readonly key: StringArrayKey; readonly label: string }[]; + +function emptyRelationship(): RelationshipDraft { + return { personName: "", relationshipName: "", description: "", importance: "", relationshipType: "", currentStatus: "" }; +} + +function emptyTrait(): TraitDraft { + return { trait: "", description: "" }; +} + +function emptyBackground(): BackgroundDraft { + return { topic: "", description: "" }; +} + +function emptyMemory(): MemoryDraft { + return { title: "", content: "", emotion: "" }; +} + +function replaceAt(rows: readonly Row[], index: number, row: Row): readonly Row[] { + return rows.map((current, currentIndex) => currentIndex === index ? row : current); +} + +function removeAt(rows: readonly Row[], index: number): readonly Row[] { + return rows.filter((_, currentIndex) => currentIndex !== index); +} + +export function CharacterOptionalFields({ onChange, regionEditable, value }: { readonly onChange: (value: CharacterOptionalFieldsValue) => void; readonly regionEditable: boolean; readonly value: CharacterOptionalFieldsValue }) { + return ( +
+
+

선택 프로필

+

비워 두면 생성 요청에서는 제외하고, 수정 요청에서는 null로 저장합니다.

+
+
+ {scalarFields.map((field) => ( + + ))} + {regionEditable ? ( + + ) : null} +
+
+ {stringArrayFields.map((field) => ( +