Files
ReSekai/src/pages/NPCPage.tsx
T

376 lines
12 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useState, useEffect } from "react";
import { useAuth } from "../contexts/AuthContext";
import {
getNPCCharacters,
createNPCCharacter,
updateNPCCharacter,
deleteNPCCharacter,
} from "../services/api";
import { generateAvatarUrl } from "../services/imageGen";
import type { NPCCharacter, CharacterAge, CharacterGender } from "../types";
import "./NPCPage.css";
const AGE_OPTIONS: { value: CharacterAge; label: string }[] = [
{ value: "child", label: "Ребёнок" },
{ value: "teenager", label: "Подросток" },
{ value: "adult", label: "Взрослый" },
{ value: "elderly", label: "Пожилой" },
];
const ROLE_OPTIONS = [
"Союзник",
"Злодей",
"Наставник",
"Романс",
"Нейтральный NPC",
"Антагонист",
"Комик",
];
const GENDER_OPTIONS: { value: CharacterGender; label: string }[] = [
{ value: "female", label: "Женский" },
{ value: "male", label: "Мужской" },
];
export default function NPCPage() {
const { user } = useAuth();
const [npcs, setNpcs] = useState<NPCCharacter[]>([]);
const [loading, setLoading] = useState(true);
const [showForm, setShowForm] = useState(false);
const [editingNpc, setEditingNpc] = useState<NPCCharacter | null>(null);
const [generatingAvatar, setGeneratingAvatar] = useState(false);
// Форма
const [name, setName] = useState("");
const [description, setDescription] = useState("");
const [role, setRole] = useState("Союзник");
const [age, setAge] = useState<CharacterAge>("adult");
const [gender, setGender] = useState<CharacterGender>("female");
const [isNsfw, setIsNsfw] = useState(false);
const [avatarUrl, setAvatarUrl] = useState("");
const [customPrompt, setCustomPrompt] = useState("");
useEffect(() => {
loadNPCs();
}, []);
const loadNPCs = async () => {
setLoading(true);
const data = await getNPCCharacters();
setNpcs(data);
setLoading(false);
};
const resetForm = () => {
setName("");
setDescription("");
setRole("Союзник");
setAge("adult");
setGender("female");
setIsNsfw(false);
setAvatarUrl("");
setCustomPrompt("");
setEditingNpc(null);
setShowForm(false);
};
const openEditForm = (npc: NPCCharacter) => {
setName(npc.name);
setDescription(npc.description);
setRole(npc.role);
setAge(npc.age);
setGender(npc.gender || "female");
setIsNsfw(npc.isNsfw || false);
setAvatarUrl(npc.avatarUrl || "");
setEditingNpc(npc);
setShowForm(true);
};
const handleGenerateAvatar = async () => {
// Если нет ни кастомного промпта, ни описания - ошибка
if (!customPrompt.trim() && !description.trim()) {
alert("Введите промпт или описание персонажа");
return;
}
setGeneratingAvatar(true);
try {
const url = await generateAvatarUrl({
description,
name,
age,
gender,
customPrompt: customPrompt.trim() || undefined,
isNsfw,
});
setAvatarUrl(url);
} catch (error) {
console.error("Ошибка генерации аватара:", error);
alert("Ошибка генерации аватара: " + (error as Error).message);
} finally {
setGeneratingAvatar(false);
}
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!name.trim() || !description.trim()) {
alert("Заполните имя и описание");
return;
}
const npcData = {
name: name.trim(),
description: description.trim(),
role,
age,
gender,
isNsfw,
avatarUrl: avatarUrl || undefined,
};
if (editingNpc) {
const success = await updateNPCCharacter(editingNpc.id, npcData);
if (success) {
await loadNPCs();
resetForm();
} else {
alert("Ошибка обновления NPC");
}
} else {
const created = await createNPCCharacter(npcData);
if (created) {
await loadNPCs();
resetForm();
} else {
alert("Ошибка создания NPC");
}
}
};
const handleDelete = async (id: string) => {
if (!confirm("Удалить этого NPC?")) return;
const success = await deleteNPCCharacter(id);
if (success) {
await loadNPCs();
} else {
alert("Ошибка удаления");
}
};
if (!user) {
return (
<div className="npc-page">
<div className="auth-required">
<h2>Требуется авторизация</h2>
<p>Войдите через Discord для доступа к NPC персонажам</p>
</div>
</div>
);
}
return (
<div className="npc-page">
<div className="npc-header">
<h1>🎭 NPC Персонажи</h1>
<p>Создавайте и управляйте NPC для ваших историй</p>
<button className="btn-create-npc" onClick={() => setShowForm(true)}>
+ Создать NPC
</button>
</div>
{showForm && (
<div className="npc-form-overlay" onClick={() => resetForm()}>
<div className="npc-form-modal" onClick={(e) => e.stopPropagation()}>
<h2>{editingNpc ? "Редактировать NPC" : "Создать NPC"}</h2>
<form onSubmit={handleSubmit}>
<div className="form-row">
<div className="form-left">
<div className="form-group">
<label>Имя персонажа</label>
<input
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="Введите имя..."
required
/>
</div>
<div className="form-group">
<label>Роль</label>
<select
value={role}
onChange={(e) => setRole(e.target.value)}
>
{ROLE_OPTIONS.map((r) => (
<option key={r} value={r}>
{r}
</option>
))}
</select>
</div>
<div className="form-group">
<label>Возраст</label>
<select
value={age}
onChange={(e) => setAge(e.target.value as CharacterAge)}
>
{AGE_OPTIONS.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
</div>
<div className="form-group">
<label>Пол</label>
<select
value={gender}
onChange={(e) =>
setGender(e.target.value as CharacterGender)
}
>
{GENDER_OPTIONS.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
</div>
<div className="form-group form-checkbox">
<label>
<input
type="checkbox"
checked={isNsfw}
onChange={(e) => setIsNsfw(e.target.checked)}
/>
NSFW (18+)
</label>
</div>
<div className="form-group">
<label>Описание</label>
<textarea
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="Опишите внешность и характер персонажа..."
rows={5}
required
/>
</div>
</div>
<div className="form-right">
<div className="avatar-section">
<div className="avatar-preview-large">
{avatarUrl ? (
<img src={avatarUrl} alt={name} />
) : (
<div className="avatar-placeholder-large">👤</div>
)}
</div>
<button
type="button"
className="btn-generate-avatar"
onClick={handleGenerateAvatar}
disabled={
generatingAvatar ||
(!customPrompt.trim() && !description.trim())
}
>
{generatingAvatar ? "Генерация..." : "🎨 Сгенерировать"}
</button>
<textarea
value={customPrompt}
onChange={(e) => setCustomPrompt(e.target.value)}
placeholder="Кастомный промпт (на английском). Если пусто - сгенерируется из описания, пола и возраста"
className="avatar-prompt-input"
rows={3}
/>
<input
type="text"
value={avatarUrl}
onChange={(e) => setAvatarUrl(e.target.value)}
placeholder="Или вставьте URL изображения..."
className="avatar-url-input"
/>
</div>
</div>
</div>
<div className="form-actions">
<button
type="button"
className="btn-cancel"
onClick={resetForm}
>
Отмена
</button>
<button type="submit" className="btn-save">
{editingNpc ? "Сохранить" : "Создать"}
</button>
</div>
</form>
</div>
</div>
)}
{loading ? (
<div className="loading">Загрузка...</div>
) : npcs.length === 0 ? (
<div className="empty-state">
<p>У вас пока нет NPC персонажей</p>
<p>Создайте первого персонажа для ваших историй!</p>
</div>
) : (
<div className="npc-grid">
{npcs.map((npc) => (
<div key={npc.id} className="npc-card">
<div className="npc-card-avatar">
{npc.avatarUrl ? (
<img src={npc.avatarUrl} alt={npc.name} />
) : (
<div className="npc-avatar-placeholder">👤</div>
)}
</div>
<div className="npc-card-info">
<h3>{npc.name}</h3>
<span className="npc-role">{npc.role}</span>
<span className="npc-age">
{AGE_OPTIONS.find((a) => a.value === npc.age)?.label ||
npc.age}
</span>
{npc.isNsfw && <span className="npc-nsfw">18+</span>}
<p className="npc-description">{npc.description}</p>
</div>
<div className="npc-card-actions">
<button
className="btn-edit"
onClick={() => openEditForm(npc)}
title="Редактировать"
>
</button>
<button
className="btn-delete"
onClick={() => handleDelete(npc.id)}
title="Удалить"
>
🗑
</button>
</div>
</div>
))}
</div>
)}
</div>
);
}